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
+364
View File
@@ -0,0 +1,364 @@
#ImportOrder 26
# MSAL implementation of AuthenticationProvider.
#
# Naming convention: every concrete auth provider is named Authentication<Backend>
# so they sort together in the Classes/ folder (AuthenticationMgGraph, AuthenticationOAuth).
#
# This class is a facade over the MSAL functions in Internal/AuthenticationMSALHelpers.ps1
# (Connect-EntraEnvironment / Connect-WithClientCredentials / Get-FullToken). Consumers
# reach MSAL through the provider abstraction: Invoke-MSGraphAPI resolves a call's owning
# provider by TokenId and asks it for the bearer via GetAccessToken.
class AuthenticationMSAL : AuthenticationProvider {
# TokenIds currently inside a pre-flight silent refresh. Used by GetAccessToken
# to break the re-entrancy cycle: Connect-EntraEnvironment itself calls back
# into Graph (Organization / ME / photo) and those calls land here for the
# bearer header. Without a guard the nested call sees the still-cached
# expired token and recurses into Connect-EntraEnvironment forever — caught
# in the wild as a "call depth overflow" crash. The first-in caller drives
# the refresh; nested calls return the cached bearer (which Connect's inner
# Invoke-MSGraphAPI -SkipAuthentication can already cope with).
static [hashtable]$Refreshing = @{}
AuthenticationMSAL() {
$this.Id = "MSAL"
$this.DisplayName = "Microsoft Authentication Library"
# Required capabilities — all true (Interactive / ClientSecret / Certificate
# default to $true on the base class). Set explicitly here as a contract
# marker so a future edit can't accidentally turn one off.
$this.SupportsInteractive = $true
$this.SupportsClientSecret = $true
$this.SupportsCertificate = $true
# Optional capability: MSAL.NET supports federated credentials via
# WithClientAssertion + managed identity via WithAzureMSI, but
# Connect-IntuneManagement does NOT expose those paths yet. Flag stays
# $false until the parameter sets are added.
$this.SupportsIdentityProvider = $false
# MSAL accepts BYO bearer tokens through Add-BYOTokenInfo.
$this.SupportsBYOToken = $true
# MSAL re-mints tokens for CAE claims challenges (silent, escalating to
# interactive when the caller allows it). See GetClaimsToken.
$this.SupportsClaimsChallenge = $true
# This provider's flows run through the built-in Connect-EntraEnvironment /
# Connect-WithClientCredentials entry points (see UsesBuiltInConnectPath on the
# base): Connect-IntuneManagement, the interactive-login helper, and the profile
# Refresh action drive MSAL through those functions directly, keeping the rich
# cloud/token-id handling and behaviour identical to the pre-abstraction path.
$this.UsesBuiltInConnectPath = $true
# MSAL can launch an interactive consent prompt via Start-MSALConsentPrompt.
$this.SupportsConsentPrompt = $true
}
# No initialization work — MSAL DLLs and settings are wired by Invoke-MSALInitialize
# which runs from AuthenticationMSALHelpers.ps1 at module load.
[void] Initialize() { }
[PSCustomObject] Connect([hashtable]$Arguments) {
# Two entry points historically:
# Connect-IntuneManagement : public API with explicit Secret / Certificate / Token
# Connect-EntraEnvironment : internal — interactive, silent, refresh
# Pick based on which fields are present in $Arguments.
# Copy first so any Cloud→legacy translation we do here doesn't mutate the
# caller's hashtable. We translate Cloud→GraphEnvironment+GCCType because the
# downstream MSAL functions haven't migrated to the new enum yet (Phase 4).
$local = @{}
foreach($key in $Arguments.Keys) { $local[$key] = $Arguments[$key] }
if($local.ContainsKey('Cloud') -and $local.Cloud -and -not $local.ContainsKey('GraphEnvironment')) {
$entry = Get-CloudByValue ([string]$local.Cloud)
if($entry) {
$local['GraphEnvironment'] = $entry.LegacyEnv
if($entry.LegacyGCC) { $local['GCCType'] = $entry.LegacyGCC }
}
}
if($local.ContainsKey('Secret') -or $local.ContainsKey('Certificate') -or
$local.ContainsKey('CertificatePath') -or $local.ContainsKey('Token')) {
# Connect-IntuneManagement now understands -Cloud directly; we still pass
# the legacy params for compatibility (Connect-IntuneManagement re-resolves
# them with -Cloud taking precedence).
return (Connect-IntuneManagement @local)
}
# Connect-EntraEnvironment uses the internal -Environment parameter (the MSAL
# function predates our public taxonomy). Translate before splatting so the
# cloud picker dialog's choice actually reaches MSAL. GCCType is NOT a
# Connect-EntraEnvironment parameter (only Connect-WithClientCredentials
# / Add-BYOTokenInfo take it) — splatting it triggers "Cannot bind
# positional parameters"; drop it. Cloud IS a parameter on
# Connect-EntraEnvironment now, so we no longer need to drop it either.
if($local.ContainsKey('GraphEnvironment')) {
$local['Environment'] = $local['GraphEnvironment']
$local.Remove('GraphEnvironment') | Out-Null
}
if($local.ContainsKey('GCCType')) { $local.Remove('GCCType') | Out-Null }
return (Connect-EntraEnvironment @local)
}
[bool] Disconnect([int]$TokenId) {
try {
Disconnect-EntraEnvironment -TokenID $TokenId
return $true
}
catch {
Write-LogError "MSAL Disconnect failed for TokenId $TokenId" $_.Exception
return $false
}
}
[bool] Refresh([int]$TokenId) {
try {
return [bool](Connect-EntraEnvironment -TokenId $TokenId -ForceRefresh)
}
catch {
Write-LogError "MSAL Refresh failed for TokenId $TokenId" $_.Exception
return $false
}
}
# MSAL is the one provider that can do this: the same account can mint a second
# token for the Azure Resource Manager audience, which is the only API that
# enumerates a user's tenants (see Internal/EntraTenantList.ps1 for why Graph
# cannot). Returns the result object that file documents, or $null when there
# is no usable MSAL session to ask with.
[PSCustomObject] GetAccessibleTenants([int]$TokenId) {
$tokenInfo = Get-FullToken $TokenId
if(-not $tokenInfo -or -not $tokenInfo.App -or -not $tokenInfo.Token -or -not $tokenInfo.Token.Account) {
Write-LogDebug "MSAL GetAccessibleTenants: no usable token for id $TokenId"
return $null
}
# Only offer the no-prompt interactive fallback once the UI is up; in a
# script there is nobody to answer a window that may appear.
$interactive = ($script:MainAppStarted -eq $true)
return (Get-EntraAccessibleTenant -App $tokenInfo.App -Account $tokenInfo.Token.Account `
-TenantId $tokenInfo.Token.TenantId -Cloud $tokenInfo.Cloud -AllowInteractive:$interactive)
}
# Silent startup resume — re-establish the last session from the persisted MSAL
# cache without prompting. Invoked once from Invoke-AuthCoreOnAppInitialized for
# the active provider; restores the old Connect-MSALUser -Silent startup logon
# that made the app auto-sign-in on launch. The fresh (no -TokenId) path resolves
# the account from the on-disk cache via GetAccountsAsync matched against the
# persisted LastLoggedOnUserId. -ForceSilent guarantees no interactive prompt: if
# there is no cached account (or the broker can't silently reissue), it simply
# returns $false and the user signs in manually. -DefaultToken makes the resumed
# session the active default so the UI shows signed-in.
[bool] TryResumeSession() {
# Respect the "Remember Login" toggle — if the user disabled caching there is
# nothing to resume and we should not touch the account cache.
if(-not (Get-SettingValue "CacheMSALToken")) { return $false }
# Cheap probe (settings + file existence only) BEFORE the MSAL runtime loads:
# a fresh box with no cached session skips the DLL load entirely.
if(-not (Test-MSALResumeLikely)) {
Write-LogDebug "MSAL TryResumeSession skipped - no cached session to resume"
return $false
}
try {
$result = Connect-EntraEnvironment -ForceSilent -DefaultToken
return [bool]$result
}
catch {
Write-LogError "MSAL TryResumeSession failed" $_.Exception
return $false
}
}
# Silent ambient refresh on view activation. Gated on the base no-op for other
# providers so MgGraph mode isn't hijacked (a successful silent MSAL auth here would
# flip the active provider). No -DefaultToken: this only refreshes, never promotes.
[void] RefreshAmbientSession() {
Connect-EntraEnvironment -ForceSilent | Out-Null
}
# Native session inspector rows for the profile "Session Info" dialog: the MSAL
# AuthenticationResult fields (minus the raw tokens).
# Decoded id-token JWT for the profile popup's Id Token inspector. Reads MSAL's
# own token entry from the registry; returns $null when there's no id token so the
# UI hides the button. This keeps the id-token JWT confined to the MSAL provider.
[object] GetIdTokenJwt([int]$TokenId) {
$t = Get-FullToken $TokenId
if($t -and $t.JWTIdToken) { return $t.JWTIdToken }
return $null
}
[PSCustomObject[]] GetSessionInfoRows() {
$rows = @()
if($script:MSALDefaultToken -and $script:MSALDefaultToken.Token) {
foreach($prop in ($script:MSALDefaultToken.Token | Get-Member | Where-Object MemberType -eq Property)) {
if($prop.Name -in @("AccessToken", "IdToken")) { continue }
$value = if($prop.Name -eq "Scopes") { ($script:MSALDefaultToken.Token.Scopes -join "`n") }
elseif($prop.Name -in @("ExpiresOn", "ExtendedExpiresOn")) { $script:MSALDefaultToken.Token."$($prop.Name)".LocalDateTime }
else { $script:MSALDefaultToken.Token."$($prop.Name)" }
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
}
}
return [PSCustomObject[]]$rows
}
[bool] ForgetAccount([string]$AccountIdentifier) {
if(-not $script:MSALAccounts) { return $false }
$account = $script:MSALAccounts | Where-Object {
$_.Username -eq $AccountIdentifier -or
$_.HomeAccountId.Identifier -eq $AccountIdentifier
} | Select-Object -First 1
if(-not $account) { return $false }
Remove-MSALAccount -Account $account
return $true
}
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
$tok = Get-FullToken $TokenId
if(-not $tok -or -not $tok.Token) { return $null }
# Pre-flight refresh near expiry to avoid mid-batch 401s. Today the resource is
# always Graph (the MSAL token caches a single audience); when other resources
# are supported in a future phase, the provider should mint per-resource tokens
# here via AcquireTokenSilent.WithScopes(resource/.default).
#
# Re-entrancy guard: Connect-EntraEnvironment internally calls Invoke-MSGraphAPI
# ('Organization', 'ME', photo) before its own returns; those calls land back
# in this method for the bearer header. The cached token is still the expired
# one at that point — the new token isn't installed until Connect's
# Add-MSALTokenInfo runs at the tail. Without a guard the nested call retries
# the refresh, which calls Invoke-MSGraphAPI, which re-enters here, etc., until
# PowerShell's call-depth limit aborts.
if($tok.Token.ExpiresOn -lt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
-not [AuthenticationMSAL]::Refreshing.ContainsKey($TokenId)) {
[AuthenticationMSAL]::Refreshing[$TokenId] = $true
# Only let a refresh miss raise AuthenticationFailed once the token has
# ACTUALLY expired. Within the 5-minute pre-flight window the current token
# is still usable, so a silent-refresh miss (e.g. the WAM broker failing on
# the refresh round-trip) must stay quiet - otherwise every near-expiry Graph
# call falsely reports a failed login even though the call then succeeds on
# the still-valid token. When truly expired, stay loud so the UI signs out.
$tokenStillValid = $tok.Token.ExpiresOn -gt [DateTimeOffset]::UtcNow
try {
# Plain silent acquire (NO -ForceRefresh). AcquireTokenSilent already
# renews an expired/near-expired access token from the refresh token
# (or via the WAM broker) on its own. Forcing a refresh here made the
# broker re-contact Entra ~5 min before every expiry, and WAM can surface
# an interactive window on that forced round-trip - that was the ~70-min
# re-login. The old 3.9.6 build never force-refreshed routinely (only on
# an explicit user "Force refresh" link); this matches it. -ForceRefresh
# is still used by Refresh() and the UI Refresh button where it is wanted.
[void](Connect-EntraEnvironment -TokenId $TokenId -ForceSilent -SuppressFailedEvent:$tokenStillValid)
}
finally {
[AuthenticationMSAL]::Refreshing.Remove($TokenId) | Out-Null
}
$tok = Get-FullToken $TokenId
if(-not $tok -or -not $tok.Token) { return $null }
}
return $tok.Token.AccessToken
}
# Satisfy a CAE claims challenge. Silent re-acquire first (broker/WAM can often
# satisfy a CAE / sign-in-frequency challenge without a visible prompt); if that
# fails and the caller allows interaction, escalate to an interactive acquire with
# the same claims so the challenge is met with a single prompt instead of a dead
# 401. When $AllowInteractive is $false (headless / nested auth-flow call) this
# stays silent-only and returns $null if the challenge can't be met.
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceSilent)
$token = $this.GetAccessToken($TokenId, $Resource)
if(-not $token -and $AllowInteractive) {
Write-Log "CAE challenge could not be satisfied silently. Escalating to interactive login." 2
[void](Connect-EntraEnvironment -TokenId $TokenId -ClaimsChallenge $ClaimsChallenge -ForceInteractive)
$token = $this.GetAccessToken($TokenId, $Resource)
}
return $token
}
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
$tok = Get-FullToken $TokenId
if(-not $tok -or -not $tok.Token -or -not $tok.Token.ExpiresOn) {
return [datetime]::MaxValue
}
return $tok.Token.ExpiresOn.LocalDateTime
}
[PSCustomObject] GetUserInfo([int]$TokenId) {
$tok = Get-FullToken $TokenId
if(-not $tok) { return $null }
$authType = if($tok.AuthType) { $tok.AuthType } else { "Interactive" }
$expires = $null
if($tok.Token -and $tok.Token.ExpiresOn) { $expires = $tok.Token.ExpiresOn.LocalDateTime }
$upn = $null
if($tok.Token -and $tok.Token.Account) { $upn = $tok.Token.Account.Username }
$userId = $null
if($tok.Token -and $tok.Token.Account -and $tok.Token.Account.HomeAccountId) {
$userId = $tok.Token.Account.HomeAccountId.ObjectId
}
return [PSCustomObject]@{
Provider = $this.Id
DisplayName = $upn
UPN = $upn
UserId = $userId
TenantId = (?: $tok.Token $tok.Token.TenantId $null)
TenantName = (?: $tok.Organization $tok.Organization.displayName $null)
AppId = (?: $tok.EntraApp $tok.EntraApp.ClientId $null)
AppName = (?: $tok.EntraApp $tok.EntraApp.Name $null)
AuthType = $authType
ExpiresOn = $expires
}
}
[PSCustomObject[]] GetCachedAccounts() {
# Lazy-refresh from the on-disk MSAL cache. Same trick the UI already does in
# Get-MSALUserProfile, but exposed at the provider level so any consumer
# (CLI scripts, automation) sees the same list.
if(($script:MSALAccounts | Measure-Object).Count -eq 0) {
try {
$app = $script:MSALApps | Select-Object -First 1
if(-not $app) { $app = New-MSALApp }
if($app) {
$script:MSALAccounts = $app.GetAccountsAsync().GetAwaiter().GetResult()
}
}
catch {
Write-LogDebug "MSAL GetCachedAccounts refresh failed: $($_.Exception.Message)"
}
}
if(-not $script:MSALAccounts) { return [PSCustomObject[]]@() }
$rows = foreach($acc in $script:MSALAccounts) {
[PSCustomObject]@{
Provider = $this.Id
Username = $acc.Username
UserId = $acc.HomeAccountId.ObjectId
TenantId = $acc.HomeAccountId.TenantId
Native = $acc
}
}
return [PSCustomObject[]]@($rows)
}
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
$tok = Get-FullToken $TokenId
if(-not $tok -or -not $tok.Tenants) { return [PSCustomObject[]]@() }
$rows = foreach($t in $tok.Tenants) {
[PSCustomObject]@{
Provider = $this.Id
TenantId = $t.tenantId
TenantName = $t.displayName
Native = $t
}
}
return [PSCustomObject[]]@($rows)
}
}
+652
View File
@@ -0,0 +1,652 @@
#ImportOrder 26
# Microsoft.Graph PowerShell SDK implementation of AuthenticationProvider.
#
# Wraps the Microsoft.Graph.Authentication module (Connect-MgGraph, Disconnect-MgGraph,
# Get-MgContext). The SDK itself uses MSAL.NET underneath but with its own session
# state and its own on-disk token cache, separate from our MSAL provider — by design,
# per the user's decision to keep caches separate.
#
# Status:
# * The class always registers (even without the SDK installed) so it is selectable
# in Settings; the SDK modules are resolved / prompted-for on first Connect.
# * `Connect-IntuneManagement -Provider MgGraph -...` routes here.
# * Invoke-MSGraphAPI routes requests for MgGraph-owned tokens through this provider:
# when the SDK's opaque cache can't yield a raw bearer, the request runs via the
# provider's own pipeline (see InvokeWebRequest / Invoke-MgGraphRequestAsWebResponse).
#
# Token-extraction note: the SDK does not expose the raw access token via a public
# cmdlet. We reach into [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance
# which is the documented (in source) but undocumented (in MS Learn) accessor. In SDK
# v2 the AccessToken is a SecureString; we unprotect at the last moment.
#
# Minimum SDK version for CAE: Microsoft.Graph.Authentication 2.37.0+ is recommended.
# Earlier versions had a token-cache bug (fixed by PR #3573, May 2026) where the
# `caeEnabled: true` capability was not included when caching tokens — so a CAE
# claim-challenge round-trip could re-prompt instead of resolving silently. Older
# SDK versions still work for non-CAE flows.
class AuthenticationMgGraph : AuthenticationProvider {
AuthenticationMgGraph() {
$this.Id = "MgGraph"
$this.DisplayName = "Microsoft Graph PowerShell SDK"
# Required capabilities (see AuthenticationProvider contract).
$this.SupportsInteractive = $true
$this.SupportsClientSecret = $true
$this.SupportsCertificate = $true
# Optional: SDK has -Identity flag for managed identity.
$this.SupportsIdentityProvider = $true
# Optional: SDK accepts -AccessToken.
$this.SupportsBYOToken = $true
# The SDK keeps cached accounts in a private InMemoryTokenCache byte[] (the
# serialized MSAL-v3 cache). GetCachedAccounts() reaches in via reflection
# and rehydrates an MSAL public-client app to enumerate the accounts —
# source pattern: github.com/microsoftgraph/msgraph-sdk-powershell.
# NOTE: this cache is in-memory only (NOT persisted to disk) — accounts only
# show up within the current PowerShell session, and clicking one cannot
# "switch to" that account because Connect-MgGraph has no -LoginHint
# parameter. The list is informational; the user must re-Connect-MgGraph
# to change accounts.
$this.SupportsCachedUsers = $true
# SDK has no per-call tenant switching — you Disconnect and Connect with a
# different -TenantId. The active session is single-tenant.
$this.SupportsMultiTenant = $false
# The SDK refreshes internally, but a manual "Refresh" action is meaningful
# for users — we re-trigger Connect-MgGraph (silent if the cache has a
# valid refresh token, interactive otherwise).
$this.SupportsRefresh = $true
# No way to evict a single cached account from the SDK's token cache via
# public cmdlets. Disconnect-MgGraph clears the active session only.
$this.SupportsForget = $false
}
[void] Initialize() {
# Module presence check happens at registration time in
# Internal/AuthenticationMgGraphHelpers.ps1 — by the time we get here, the SDK is
# known to be installed. We do NOT eagerly Import-Module (load cost is
# ~hundreds of ms); the first Connect() call imports lazily.
}
# The SDK v2 in-memory token cache is opaque, so we can't hand Invoke-MSGraphAPI a
# raw bearer. Route the request through the SDK's own pipeline (which auths it) and
# wrap the result so it quacks like Invoke-WebRequest's response.
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
return (Invoke-MgGraphRequestAsWebResponse -Url $Url -Method $Method -Body $Body -Headers $Headers)
}
# Native session inspector rows for the profile "Session Info" dialog: Get-MgContext
# properties (the closest MgGraph equivalent of MSAL's AuthenticationResult).
[PSCustomObject[]] GetSessionInfoRows() {
$rows = @()
try {
$ctx = Get-MgContext -ErrorAction SilentlyContinue
if($ctx) {
foreach($prop in ($ctx | Get-Member -MemberType Properties)) {
$value = $ctx."$($prop.Name)"
if($prop.Name -eq "Scopes" -and $value) { $value = ($value -join "`n") }
if($value -is [SecureString]) { $value = "<SecureString>" }
$rows += [PSCustomObject]@{ Name = $prop.Name; Value = $value }
}
}
} catch { }
return [PSCustomObject[]]$rows
}
# Silent cross-session resume. Microsoft.Graph SDK v2 persists credentials by
# default (ContextScope.CurrentUser): the MSAL cache lives at
# %LOCALAPPDATA%\.IdentityService\mg.msal.cache and the AuthenticationRecord
# anchor at %USERPROFILE%\.mg\mg.authrecord.json. Both must exist; if so, a
# plain Connect-MgGraph -NoWelcome silently rehydrates the session via
# Azure.Identity's MsalCacheHelper. No browser, no prompt.
[bool] TryResumeSession() {
try {
if(-not (Resolve-MgGraphModule)) { return $false }
$anchor = Join-Path $env:USERPROFILE ".mg\mg.authrecord.json"
if(-not (Test-Path $anchor)) {
Write-LogDebug "MgGraph: no auth record at $anchor - skipping silent resume"
return $false
}
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
}
catch {
Write-LogError "MgGraph TryResumeSession: failed to import Microsoft.Graph.Authentication" $_.Exception
return $false
}
# If a context is already active (e.g. another caller already connected
# during this session), respect it.
$existing = $null
try { $existing = Get-MgContext -ErrorAction SilentlyContinue } catch { }
if($existing) {
Write-Log "MgGraph: already signed in as $($existing.Account) (tenant $($existing.TenantId)); silent resume not needed"
return $true
}
Write-Log "MgGraph: attempting silent resume from persisted Azure.Identity cache..."
# NoWelcome suppresses banner; no Scopes parameter means Azure.Identity
# uses whatever scopes were in the AuthenticationRecord. If the cache or
# record is stale, Connect-MgGraph will throw / require interaction —
# we treat any failure as "resume not possible, user must click Login".
Connect-MgGraph -NoWelcome -ErrorAction Stop | Out-Null
$ctx = $null
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
if($ctx) {
Write-Log "MgGraph: silent resume succeeded - signed in as $($ctx.Account) (tenant $($ctx.TenantId))"
return $true
}
Write-Log "MgGraph: Connect-MgGraph completed but Get-MgContext returned nothing - silent resume failed" 2
return $false
}
catch {
Write-LogDebug "MgGraph: silent resume failed: $($_.Exception.Message)"
return $false
}
}
[PSCustomObject] Connect([hashtable]$Arguments) {
Write-Log "AuthenticationMgGraph.Connect starting"
# Step 1: ensure the required SDK module is available. If not, offer to
# install it. If the user declines or install fails, return $null so
# Connect-IntuneManagement can fall back to MSAL.
if(-not (Resolve-MgGraphModule)) {
Write-Log "Microsoft.Graph.Authentication not available - MgGraph provider cannot connect" 2
return $null
}
try {
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop | Out-Null
}
catch {
Write-LogError "Failed to import Microsoft.Graph.Authentication" $_.Exception
return $null
}
$mgArgs = @{ NoWelcome = $true }
if($Arguments.TenantId) { $mgArgs['TenantId'] = $Arguments.TenantId }
if($Arguments.AppId) { $mgArgs['ClientId'] = $Arguments.AppId }
# Cloud / sovereign environment selection. Prefer the flat -Cloud arg
# (Phase 1, 2026-05-22). Fall back to translating the legacy GraphEnvironment+GCCType
# pair so direct provider callers passing the old shape still work during the
# deprecation window. Connect-MgGraph -Environment accepts: Global / USGov / USGovDOD / China.
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud }
elseif($Arguments.GraphEnvironment -or $Arguments.GCCType) {
Convert-LegacyToCloud -GraphEnvironment ([string]$Arguments.GraphEnvironment) -GCCType ([string]$Arguments.GCCType)
}
else { "Public" }
$cloudEntry = Get-CloudByValue $cloudValue
$mgEnv = $cloudEntry.MgEnvironment
if($mgEnv -and $mgEnv -ne "Global") {
$mgArgs['Environment'] = $mgEnv
Write-LogDebug "MgGraph: using -Environment $mgEnv (Cloud=$cloudValue)"
}
# Dispatch on auth method. Connect-MgGraph parameter sets are mutually
# exclusive, so we pick exactly one.
if($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
if(-not $Arguments.AppId) { Write-Log "MgGraph: -AppId required with -Secret" 3; return $null }
if(-not $Arguments.TenantId) { Write-Log "MgGraph: -TenantId required with -Secret" 3; return $null }
$secStr = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret }
else { ConvertTo-SecureString ([string]$Arguments.Secret) -AsPlainText -Force }
$mgArgs['ClientSecretCredential'] = [PSCredential]::new($Arguments.AppId, $secStr)
# ClientId + TenantId are conveyed via the credential here; remove the
# standalone entries so we don't conflict with the credential parameter set.
$mgArgs.Remove('ClientId') | Out-Null
}
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
if($Arguments.Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
$mgArgs['Certificate'] = $Arguments.Certificate
}
else {
# Treat as thumbprint string
$mgArgs['CertificateThumbprint'] = [string]$Arguments.Certificate
}
}
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
$cert = Get-PfxCertificate -FilePath $Arguments.CertificatePath -Password $Arguments.CertificatePassword -ErrorAction Stop
$mgArgs['Certificate'] = $cert
}
elseif($Arguments.ContainsKey('Token') -and $Arguments.Token) {
$tokStr = if($Arguments.Token -is [SecureString]) { $Arguments.Token }
else { ConvertTo-SecureString ([string]$Arguments.Token) -AsPlainText -Force }
$mgArgs['AccessToken'] = $tokStr
}
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity) {
$mgArgs['Identity'] = $true
# For user-assigned managed identity, the user can pass a specific client id.
if($Arguments.ManagedIdentityClientId) { $mgArgs['ClientId'] = $Arguments.ManagedIdentityClientId }
}
elseif($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) {
# Microsoft.Graph.Authentication 2.x exposes device code as the
# -UseDeviceCode switch on Connect-MgGraph. Emits the code and
# verification URL to the console and blocks until the user
# completes auth in a browser on any device.
$mgArgs['UseDeviceCode'] = $true
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
}
else {
# Interactive. Default scopes match what the rest of the app uses; callers
# can override via $Arguments.Scopes.
if($Arguments.Scopes) { $mgArgs['Scopes'] = $Arguments.Scopes }
}
$authModeLog = if($mgArgs.ContainsKey('ClientSecretCredential')) { 'client secret' }
elseif($mgArgs.ContainsKey('Certificate')) { 'certificate' }
elseif($mgArgs.ContainsKey('CertificateThumbprint')) { 'certificate (thumbprint)' }
elseif($mgArgs.ContainsKey('AccessToken')) { 'BYO token' }
elseif($mgArgs.ContainsKey('Identity')) { 'managed identity' }
else { 'interactive (browser)' }
Write-Log "Calling Connect-MgGraph (mode: $authModeLog)..."
try {
# Wipe any stale cached token from a prior session before the new auth.
[AuthenticationMgGraph]::ClearTokenCache()
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
Write-Log "Connect-MgGraph completed successfully"
}
catch {
Write-LogError "Connect-MgGraph failed" $_.Exception
return $null
}
# Verify Get-MgContext returns a session. If it does, we're signed in —
# even if we can't pull a raw bearer token out of the SDK. Invoke-MSGraphAPI
# has an SDK-routed fallback for that case (uses Invoke-MgGraphRequest, which
# the SDK auths internally with its own in-memory cache).
$ctx = $null
try { $ctx = Get-MgContext -ErrorAction SilentlyContinue } catch { }
if(-not $ctx) {
Write-Log "Connect-MgGraph completed but Get-MgContext returned nothing. Treating as failed auth." 3
try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { }
[AuthenticationMgGraph]::ClearTokenCache()
return $null
}
$verifyToken = $this.GetAccessToken(0, "https://$($cloudEntry.GraphHost)")
if($verifyToken) {
Write-LogDebug "MgGraph session verified - token extracted ($($verifyToken.Length) chars)"
}
else {
# SDK v2 keeps tokens opaque by design. Invoke-MSGraphAPI has a routed
# fallback that uses Invoke-MgGraphRequest (the SDK auths internally),
# so this is normal — debug-level only.
Write-LogDebug "MgGraph: session valid (Get-MgContext: tenant=$($ctx.TenantId)) but raw bearer not extractable. Graph calls route via Invoke-MgGraphRequest."
}
# Realign the active auth provider so subsequent Invoke-MSGraphAPI calls route
# here. Symmetric to the same logic in MSAL's Add-MSALTokenInfo.
if(Get-Command Set-ActiveAuthProvider -ErrorAction SilentlyContinue) {
$cur = Get-AuthProvider
if($cur -and $cur.Id -ne $this.Id) {
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because MgGraph authentication succeeded"
Set-ActiveAuthProvider -Id $this.Id
}
}
# Phase 3: persist per-tenant cloud memory. Prefer the Cloud arg the caller
# asked for; fall back to mapping Get-MgContext.Environment back to a Cloud
# value (the SDK's -Environment values match ours 1:1 via Clouds[].MgEnvironment).
# Declared before the try so it's always in scope for Register-AuthToken below.
$persistCloud = if($cloudValue) { $cloudValue } else { $null }
try {
if(-not $persistCloud -and $ctx -and $ctx.Environment) {
$match = $script:Clouds | Where-Object MgEnvironment -eq $ctx.Environment | Select-Object -First 1
if($match) { $persistCloud = $match.Value }
}
if(-not $persistCloud) { $persistCloud = Get-DefaultCloud }
if($persistCloud -and $ctx.TenantId) {
Save-TenantCloud -TenantId $ctx.TenantId -Cloud $persistCloud
Save-SettingStoreValue "" "LastLoggedOnCloud" $persistCloud
}
}
catch {
Write-LogDebug "Phase 3 MgGraph cloud memory write failed: $($_.Exception.Message)"
}
# Register with the central token registry (single-session invariant: drop
# any prior entry first so repeated Connect never accumulates entries).
# The registry fires AuthenticatedNewToken with the canonical [IMAuthToken]
# - MgGraph now participates in the auth events for the first time.
if($this.CurrentTokenId -gt 0) {
Unregister-AuthToken -TokenId $this.CurrentTokenId
}
$this.CurrentTokenId = Get-NextAuthTokenId
return (Register-AuthToken -Provider $this -TokenId $this.CurrentTokenId -Cloud $persistCloud)
}
[bool] Disconnect([int]$TokenId) {
try {
Disconnect-MgGraph -ErrorAction Stop | Out-Null
[AuthenticationMgGraph]::ClearTokenCache()
if($this.CurrentTokenId -gt 0) {
Unregister-AuthToken -TokenId $this.CurrentTokenId
$this.CurrentTokenId = 0
}
return $true
}
catch {
Write-LogError "Disconnect-MgGraph failed" $_.Exception
return $false
}
}
# Re-trigger Connect-MgGraph against the current session's tenant. With a valid
# refresh token in the cache the SDK does this silently; otherwise it prompts.
[bool] Refresh([int]$TokenId) {
try {
$ctx = Get-MgContext -ErrorAction SilentlyContinue
$mgArgs = @{ NoWelcome = $true }
if($ctx -and $ctx.TenantId) { $mgArgs['TenantId'] = $ctx.TenantId }
if($ctx -and $ctx.ClientId) { $mgArgs['ClientId'] = $ctx.ClientId }
if($ctx -and $ctx.Scopes) { $mgArgs['Scopes'] = @($ctx.Scopes) }
if($ctx -and $ctx.Environment -and $ctx.Environment -ne "Global") { $mgArgs['Environment'] = $ctx.Environment }
Write-Log "MgGraph Refresh: re-running Connect-MgGraph (tenant: $($ctx.TenantId))"
# Invalidate the cached bearer so the next GetAccessToken call re-extracts.
[AuthenticationMgGraph]::ClearTokenCache()
Connect-MgGraph @mgArgs -ErrorAction Stop | Out-Null
return $true
}
catch {
Write-LogError "MgGraph Refresh failed" $_.Exception
return $false
}
}
static [void] ClearTokenCache() {
[AuthenticationMgGraph]::CachedToken = $null
[AuthenticationMgGraph]::CachedTokenExpiry = [DateTimeOffset]::MinValue
[AuthenticationMgGraph]::CachedTokenTenantId = $null
}
# Cached token + expiry to avoid hitting the SDK on every request.
static [string]$CachedToken
static [DateTimeOffset]$CachedTokenExpiry = [DateTimeOffset]::MinValue
static [string]$CachedTokenTenantId
# The single global token id for this provider's one live session. MgGraph is
# single-session by SDK design (one ambient Get-MgContext), so it holds exactly
# one registry entry at a time. 0 = not registered.
[int]$CurrentTokenId = 0
# Robust token extraction. Microsoft.Graph SDK v2 doesn't expose an access token
# accessor — AuthContext.AccessToken stays null in the delegated flow. We use the
# SDK's own HttpClient (which has its auth DelegatingHandler attached) to make a
# cheap HEAD-style request; the handler mutates the request to add
# "Authorization: Bearer <token>" before sending. We then read the header off the
# request. Same mechanism the SDK itself uses internally on every Mg* cmdlet —
# no reflection, no private APIs.
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
try {
# Return cached token if still valid (>5 min until expiry). The SDK refreshes
# internally on every call, so caching at our layer avoids per-request
# network round-trips just to "pull" a token.
$ctxTenantId = $null
try { $ctxTenantId = (Get-MgContext -ErrorAction SilentlyContinue).TenantId } catch { }
if([AuthenticationMgGraph]::CachedToken -and
[AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::UtcNow.AddMinutes(5) -and
[AuthenticationMgGraph]::CachedTokenTenantId -eq $ctxTenantId) {
return [AuthenticationMgGraph]::CachedToken
}
$sessionType = "Microsoft.Graph.PowerShell.Authentication.GraphSession" -as [type]
if(-not $sessionType) {
Write-LogDebug "MgGraph: GraphSession type not available; SDK not loaded?"
return $null
}
$session = $sessionType::Instance
if(-not $session) {
Write-LogDebug "MgGraph: GraphSession.Instance is null"
return $null
}
# The SDK exposes GraphHttpClient: an HttpClient wired with auth handlers.
$httpClient = $session.GraphHttpClient
if(-not $httpClient) {
Write-LogDebug "MgGraph: GraphHttpClient is null (Connect-MgGraph not run?)"
return $null
}
# Strategy A — direct AuthContext read (works on some SDK builds, fast-path).
if($session.AuthContext -and $session.AuthContext.AccessToken) {
$tok = [AuthenticationMgGraph]::UnprotectString($session.AuthContext.AccessToken)
if($tok) {
[AuthenticationMgGraph]::SaveTokenCache($tok, $ctxTenantId)
Write-LogDebug "MgGraph: token from AuthContext.AccessToken (fast-path)"
return $tok
}
}
# Strategy B — HttpClient sniff: make a trivial GET via the SDK's HttpClient,
# then read the Authorization header that the DelegatingHandler attached.
# This is the supported public contract of the SDK's auth pipeline.
$graphResource = if($Resource) { $Resource.TrimEnd('/') } else { "https://$(Get-GraphDomain)" }
$req = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::Get,
"$graphResource/v1.0/`$metadata")
try {
$task = $httpClient.SendAsync(
$req,
[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
# 30s timeout — interactive auth could be required if cache is cold.
if(-not $task.Wait(30000)) {
Write-LogDebug "MgGraph: HttpClient sniff timed out"
return $null
}
# Drop the response (we only care about the request headers the handler
# populated). Dispose to free the socket.
try { $task.Result.Dispose() } catch { }
}
catch {
Write-LogDebug "MgGraph: HttpClient sniff failed: $($_.Exception.Message)"
}
if($req.Headers.Authorization -and
$req.Headers.Authorization.Scheme -eq 'Bearer' -and
$req.Headers.Authorization.Parameter) {
$token = $req.Headers.Authorization.Parameter
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
Write-LogDebug "MgGraph: token via HttpClient auth-handler sniff"
return $token
}
# Last resort — reflection probe.
$token = [AuthenticationMgGraph]::FindTokenViaReflection($session)
if($token) {
[AuthenticationMgGraph]::SaveTokenCache($token, $ctxTenantId)
Write-LogDebug "MgGraph: token via reflection"
return $token
}
$ctxJson = "<null>"
if($session.AuthContext) {
try {
$ctxJson = ($session.AuthContext | Select-Object TenantId, ClientId, AppName, AuthType, @{n='AccessTokenPresent';e={[bool]$_.AccessToken}}, @{n='Scopes';e={($_.Scopes -join ', ')}} | ConvertTo-Json -Compress)
}
catch { $ctxJson = "<unserializable>" }
}
Write-Log "MgGraph: could not extract access token. AuthContext=$ctxJson" 2
return $null
}
catch {
Write-LogError "Failed to extract MgGraph access token" $_.Exception
return $null
}
}
# Persist the freshly-acquired token + expiry. Expiry parsed from the JWT exp claim;
# fall back to "now + 50 minutes" if parsing fails (Entra tokens default to 60 min).
static [void] SaveTokenCache([string]$Token, [string]$TenantId) {
[AuthenticationMgGraph]::CachedToken = $Token
[AuthenticationMgGraph]::CachedTokenTenantId = $TenantId
$expiry = [DateTimeOffset]::UtcNow.AddMinutes(50)
try {
# Decode JWT exp claim
$jwt = Get-JWTtoken $Token
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
$expiry = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp)
}
}
catch { }
[AuthenticationMgGraph]::CachedTokenExpiry = $expiry
}
# Unprotect a SecureString or pass a plain string through.
static [string] UnprotectString($Value) {
if($null -eq $Value) { return $null }
if($Value -is [SecureString]) {
return [System.Net.NetworkCredential]::new("", $Value).Password
}
return [string]$Value
}
# Walks the session object graph looking for a property/field whose name suggests it
# holds an access token. Limited to 2 levels deep to avoid infinite recursion.
static [string] FindTokenViaReflection($obj) {
if($null -eq $obj) { return $null }
try {
foreach($prop in $obj.PSObject.Properties) {
if($prop.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
$val = $prop.Value
if($val) {
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
}
}
}
foreach($prop in $obj.PSObject.Properties) {
if($prop.Name -in 'AuthContext','InMemoryTokenCache','GraphOption','RequestContext') {
$child = $prop.Value
if($child) {
foreach($childProp in $child.PSObject.Properties) {
if($childProp.Name -match 'AccessToken|BearerToken|JWT|^Token$') {
$val = $childProp.Value
if($val) {
$unwrapped = [AuthenticationMgGraph]::UnprotectString($val)
if($unwrapped -and $unwrapped.Length -gt 20) { return $unwrapped }
}
}
}
}
}
}
}
catch { }
return $null
}
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
# SDK does not expose the expiry to consumers. We return MaxValue and rely on
# MgGraph's internal silent refresh (it owns the cache).
return [datetime]::MaxValue
}
# Cached tenant display name to avoid hitting /organization on every refresh.
static [hashtable]$TenantNameCache = @{}
[PSCustomObject] GetUserInfo([int]$TokenId) {
try {
$ctx = Get-MgContext -ErrorAction SilentlyContinue
if(-not $ctx) { return $null }
# SDK reports AuthType as Delegated / AppOnly. Map to our taxonomy.
$authType = switch ($ctx.AuthType) {
"AppOnly" { "ClientCredential" }
"Delegated" { "Interactive" }
default { "$($ctx.AuthType)" }
}
# Get-MgContext doesn't surface tenant display name. Fetch it once per
# tenant via Invoke-MgGraphRequest /organization (the SDK handles auth);
# cache for the rest of the session.
$tenantName = $null
if($ctx.TenantId) {
if([AuthenticationMgGraph]::TenantNameCache.ContainsKey($ctx.TenantId)) {
$tenantName = [AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId]
}
else {
try {
$org = Invoke-MgGraphRequest -Method GET -Uri "https://$(Get-GraphDomain)/v1.0/organization" -OutputType PSObject -ErrorAction Stop
if($org -and $org.value -and $org.value.Count -gt 0 -and $org.value[0].displayName) {
$tenantName = $org.value[0].displayName
[AuthenticationMgGraph]::TenantNameCache[$ctx.TenantId] = $tenantName
}
}
catch {
Write-LogDebug "MgGraph GetUserInfo: /organization fetch failed ($($_.Exception.Message)); tenant display name will be unknown"
}
}
}
# Expiry comes from the JWT exp claim we parsed into CachedTokenExpiry.
# If no token has been minted yet this session, trigger one — cheap when
# the SDK's in-memory cache is warm.
$expiresOn = $null
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
}
else {
try {
[void]$this.GetAccessToken(0, "https://$(Get-GraphDomain)")
if([AuthenticationMgGraph]::CachedTokenExpiry -gt [DateTimeOffset]::MinValue) {
$expiresOn = [AuthenticationMgGraph]::CachedTokenExpiry.LocalDateTime
}
}
catch { }
}
return [PSCustomObject]@{
Provider = $this.Id
DisplayName = $ctx.Account
UPN = $ctx.Account
UserId = $null # Not surfaced by Get-MgContext
TenantId = $ctx.TenantId
TenantName = $tenantName
AppId = $ctx.ClientId
AppName = $ctx.AppName
AuthType = $authType
ExpiresOn = $expiresOn
}
}
catch {
return $null
}
}
[PSCustomObject[]] GetCachedAccounts() {
# Delegate to a module function — PS class method bodies are parsed strictly
# (type references like [Microsoft.Identity.Client.PublicClientApplicationBuilder]
# have to resolve at PARSE time, before MSAL DLLs are loaded). Module
# functions are late-bound and tolerate this.
$rows = @()
try {
$rows = @(Get-MgGraphCachedMsalAccounts -ProviderId $this.Id)
}
catch {
Write-LogDebug "MgGraph GetCachedAccounts failed: $($_.Exception.Message)"
}
return [PSCustomObject[]]$rows
}
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
# SDK does not surface available tenants. Tenant switching means
# Disconnect + Connect with -TenantId, which the UI can offer via a manual
# tenant id entry (Phase 3 UI concern).
return [PSCustomObject[]]@()
}
}
+145
View File
@@ -0,0 +1,145 @@
#ImportOrder 27
# Offline "mock tenant" provider.
#
# Signs in with no network and answers every Graph request from the JSON files
# under IM_MOCK_DATA (see Internal/MockGraph.ps1). Registered only when that
# variable points at a folder - a normal launch never sees it - and used for
# screenshots, demos and UI work without a tenant.
#
# The access token is a real-looking but unsigned JWT: the access-marking code
# reads scp / wids from it exactly as it would from Entra, so the menu renders
# with full access and no role lookup. Every request then goes through
# InvokeWebRequest (RoutesAllRequests) instead of HTTPS.
# What a mock 4xx throws. Invoke-MSGraphAPI reads the failure from
# $_.Exception.Response - StatusCode, Headers, and the body through
# GetResponseStream() - the same way it reads a WebException from
# Invoke-WebRequest, so a mock 404 reports its status, code and message like a
# real one. WebException.Response cannot be set from PowerShell, hence a class.
class MockGraphResponseException : System.Exception {
[object]$Response
MockGraphResponseException([string]$Message, [object]$Response) : base($Message) {
$this.Response = $Response
}
}
class AuthenticationMock : AuthenticationProvider {
static [hashtable]$Tokens = @{}
[string]$DataFolder
AuthenticationMock() {
$this.Id = "Mock"
$this.DisplayName = "Mock tenant (offline demo data)"
$this.SupportsInteractive = $true
$this.SupportsClientSecret = $false
$this.SupportsCertificate = $false
$this.SupportsIdentityProvider = $false
$this.SupportsBYOToken = $false
$this.SupportsClaimsChallenge = $false
$this.SupportsMultiTenant = $false
$this.SupportsRefresh = $true
$this.SupportsForget = $false
$this.SupportsCachedUsers = $false
$this.RoutesAllRequests = $true
}
[void] Initialize() {
$this.DataFolder = [string]$env:IM_MOCK_DATA
}
# Sign in at startup - there is nothing to prompt for.
[bool] TryResumeSession() {
if(-not $this.DataFolder) { return $false }
$token = $this.Connect(@{})
return [bool]$token
}
[PSCustomObject] Connect([hashtable]$Arguments) {
if(-not $this.DataFolder -or -not (Test-Path -LiteralPath $this.DataFolder -PathType Container)) {
Write-Log "Mock provider: IM_MOCK_DATA does not point at a folder ('$($this.DataFolder)')" 3
return $null
}
$tenant = Get-MockTenantProfile -Root $this.DataFolder
Initialize-MockGraphStore -Root $this.DataFolder | Out-Null
$tokenId = Get-NextAuthTokenId
[AuthenticationMock]::Tokens[$tokenId] = @{
Id = $tokenId
Tenant = $tenant
AccessToken = (New-MockAccessToken -Tenant $tenant)
AcquiredAt = [DateTime]::UtcNow
}
Write-Log "Mock provider: signed in to '$($tenant.TenantName)' as $($tenant.UPN) (TokenId=$tokenId)"
try {
$cur = Get-AuthProvider
if($cur -and $cur.Id -ne $this.Id) { Set-ActiveAuthProvider -Id $this.Id }
} catch { }
return (Register-AuthToken -Provider $this -TokenId $tokenId -Cloud (Get-DefaultCloud))
}
[bool] Disconnect([int]$TokenId) {
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $false }
Unregister-AuthToken -TokenId $TokenId
[AuthenticationMock]::Tokens.Remove($TokenId) | Out-Null
return $true
}
[bool] Refresh([int]$TokenId) {
return [AuthenticationMock]::Tokens.ContainsKey($TokenId)
}
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
$entry = $this.GetEntry($TokenId)
if(-not $entry) { return $null }
return [string]$entry.AccessToken
}
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
return [datetime]::MaxValue
}
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
return (Invoke-MockGraphRequest -Url $Url -Method $Method -Body $Body)
}
[PSCustomObject] GetUserInfo([int]$TokenId) {
$entry = $this.GetEntry($TokenId)
if(-not $entry) { return $null }
$tenant = $entry.Tenant
return [PSCustomObject]@{
Provider = $this.Id
DisplayName = $tenant.DisplayName
UPN = $tenant.UPN
UserId = $tenant.UserId
TenantId = $tenant.TenantId
TenantName = $tenant.TenantName
AppId = $tenant.AppId
AppName = $tenant.AppName
AuthType = "Interactive"
ExpiresOn = [DateTime]::Now.AddYears(1)
}
}
[PSCustomObject[]] GetSessionInfoRows() {
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
$rows.Add([PSCustomObject]@{ Name = "Provider"; Value = "Mock (offline)" })
$rows.Add([PSCustomObject]@{ Name = "Data folder"; Value = $this.DataFolder })
return $rows.ToArray()
}
hidden [hashtable] GetEntry([int]$TokenId) {
if($TokenId -le 0 -and [AuthenticationMock]::Tokens.Count -gt 0) {
$TokenId = ([AuthenticationMock]::Tokens.Keys | Sort-Object -Descending | Select-Object -First 1)
}
if(-not [AuthenticationMock]::Tokens.ContainsKey($TokenId)) { return $null }
return [AuthenticationMock]::Tokens[$TokenId]
}
}
+949
View File
@@ -0,0 +1,949 @@
#ImportOrder 27
# Pure-PowerShell implementation of AuthenticationProvider.
#
# No MSAL.NET DLL, no Microsoft.Graph.Authentication SDK. The class talks to
# https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token directly via
# Invoke-RestMethod. Designed for automation: scheduled tasks, CI runners,
# AKS workload identity, Azure VMs, App Service / Functions.
#
# Supported flows (dispatched by Connect() based on which arg is set, in
# this priority order):
#
# Token BYO bearer (no flow)
# DeviceCode device code grant (RFC 8628) — v2
# ManagedIdentity (no Federated*) IMDS / App Service / Functions
# FederatedTokenFile / FederatedToken Workload Identity Federation
# Certificate / CertificatePath client_credentials w/ private_key_jwt
# Secret client_credentials w/ shared secret
# Credential (PSCredential) ROPC (grant_type=password)
#
# All endpoint heavy lifting (HTTP, JWT signing, IMDS detection, error
# surfacing) lives in Internal/AuthenticationOAuthHelpers.ps1 — module
# functions are late-bound and tolerate types that don't resolve at parse
# time, which keeps this class file portable.
#
# Per-tenant cloud memory is stamped via Save-TenantCloud at the end of every
# successful Connect, matching what AuthenticationMSAL and AuthenticationMgGraph
# do. Same for AppEvents (AuthenticatedNewToken / AuthenticationTokenRefresh /
# AuthenticationUserDisconnected / AuthenticationFailed).
class AuthenticationOAuth : AuthenticationProvider {
# Token cache. Static so every reference to the provider sees the same
# store within a session. Keys are integer TokenIds (consistent with the
# other providers).
static [hashtable]$Tokens = @{}
static [int]$NextTokenId = 1
# Cached tenant display name per TenantId so GetUserInfo doesn't hit
# /organization on every refresh event. Failed lookups (e.g. app-only token
# without Organization.Read.All) cache $null so they aren't retried either.
static [hashtable]$TenantNameCache = @{}
AuthenticationOAuth() {
$this.Id = "OAuth"
$this.DisplayName = "Direct OAuth (no SDK)"
# Required contract.
$this.SupportsInteractive = $true # device code flow (RFC 8628)
$this.SupportsClientSecret = $true
$this.SupportsCertificate = $true
# Recommended.
$this.SupportsIdentityProvider = $true # IMDS + workload federation
$this.SupportsBYOToken = $true
$this.SupportsClaimsChallenge = $true # re-mints via the /token body (see GetClaimsToken)
$this.SupportsMultiTenant = $true
$this.SupportsRefresh = $true
$this.SupportsForget = $true
$this.SupportsCachedUsers = $false # No persistent on-disk cache
}
[void] Initialize() {
# Register the OAuth settings section (browser-login config). Guard on the
# settings API being loaded (module-load order); Initialize() runs from
# Register-AuthProvider, before the Settings dialog renders.
if(-not (Get-Command -Name Add-SettingsSection -ErrorAction SilentlyContinue)) { return }
if(-not (Get-Command -Name Add-SettingsObject -ErrorAction SilentlyContinue)) { return }
try {
# Order 9 = directly under the MSAL section (8). App (client) id and tenant id
# are NOT registered here - MSAL and OAuth are two ways of authenticating with
# the SAME app, so both resolve it from the common Entra settings in the
# Authentication section (Get-EntraApp: dropdown -> custom app id -> default
# Microsoft Graph PowerShell public client).
Add-SettingsSection -Title "OAuth" -Id "OAuth" -Order 9
Add-SettingsObject -Title "OAuth browser prompt" -Key "OAuthPrompt" -Type "List" -DefaultValue "select_account" `
-ItemsSource @(
[PSCustomObject]@{ Name = "Select account"; Value = "select_account" },
[PSCustomObject]@{ Name = "Force login"; Value = "login" },
[PSCustomObject]@{ Name = "Consent"; Value = "consent" },
[PSCustomObject]@{ Name = "None (silent)"; Value = "none" }
) `
-Description "OAuth /authorize prompt behaviour. 'Force login' re-authenticates even with an active browser session (equivalent to force-interactive); 'None' fails if interaction would be required." `
-Section "OAuth"
Add-SettingsObject -Title "OAuth login hint (UPN)" -Key "OAuthLoginHint" -Type "String" -DefaultValue "" `
-Description "Optional UPN to pre-fill on the sign-in page (login_hint)." `
-Section "OAuth"
Add-SettingsObject -Title "OAuth redirect port" -Key "OAuthRedirectPort" -Type "Int" -DefaultValue 0 `
-Description "Fixed loopback port for the browser redirect (http://localhost:<port>). 0 = pick a free port automatically. Set a fixed port only if your app registration requires a specific http://localhost:<port> redirect." `
-Section "OAuth"
Add-SettingsObject -Title "Remember login (cache token)" -Key "OAuthCacheToken" -Type "Boolean" -DefaultValue $false `
-Description "Persist the OAuth refresh token (DPAPI-encrypted, current user) so the app silently resumes the browser session after a restart. When off, you sign in again after each restart (usually a quick browser redirect via existing SSO)." `
-Section "OAuth"
}
catch {
Write-LogError "Failed to register OAuth settings section" $_.Exception
}
}
# Silent cross-restart resume for the browser flow. When "Remember login" is on and
# a DPAPI-cached refresh token exists, mint a fresh token via the refresh_token grant
# (no browser) and register it as the default. Returns $true on success. Base is a
# no-op; MSAL/other providers have their own resume paths.
[bool] TryResumeSession() {
try {
if((Get-SettingValue "OAuthCacheToken") -ne $true) { return $false }
$cache = Read-OAuthTokenCache
if(-not $cache -or -not $cache.RefreshToken) { return $false }
$cloudValue = if($cache.Cloud) { [string]$cache.Cloud } else { Get-DefaultCloud }
$authority = if($cache.Authority) { [string]$cache.Authority } else { (Get-CloudByValue $cloudValue).AADAuthority }
$resource = if($cache.Resource) { [string]$cache.Resource } else { "https://$((Get-CloudByValue $cloudValue).GraphHost)" }
$tenant = if($cache.TenantId) { [string]$cache.TenantId } else { 'organizations' }
$scope = "$resource/.default offline_access"
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenant -Body @{
grant_type = 'refresh_token'
client_id = $cache.ClientId
refresh_token = $cache.RefreshToken
scope = $scope
}
if(-not $tokenResp -or -not $tokenResp.access_token) { return $false }
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
if($tokenResp.expires_in) { $expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in) }
# Recover the real tenant from the token when we resumed under 'organizations'.
$tenantId = [string]$cache.TenantId
try {
$jwt = Get-JWTtoken $tokenResp.access_token
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) { $tenantId = [string]$jwt.Payload.tid }
} catch { }
$cred = [ordered]@{
AuthMethod = 'AuthCode'
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $cache.ClientId
}
$tokenId = Get-NextAuthTokenId
$entry = [ordered]@{
Id = $tokenId
AccessToken = [string]$tokenResp.access_token
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { [string]$cache.RefreshToken }
ExpiresAt = $expiresAt
TenantId = $tenantId
ClientId = $cache.ClientId
AuthMethod = 'AuthCode'
Cloud = $cloudValue
Resource = $resource
CredentialState = $cred
AcquiredAt = [DateTime]::UtcNow
}
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
# A rotated refresh token must overwrite the cached one.
if($tokenResp.refresh_token) {
[void](Save-OAuthTokenCache -Data @{
RefreshToken = [string]$tokenResp.refresh_token
ClientId = $cache.ClientId
TenantId = $tenantId
Cloud = $cloudValue
Authority = $authority
Resource = $resource
AuthMethod = 'AuthCode'
})
}
[void](Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue -Default)
Write-Log "OAuth provider: resumed cached browser session (TokenId=$tokenId, tenant=$tenantId)"
return $true
}
catch {
Write-LogError "OAuth provider: TryResumeSession failed" $_.Exception
return $false
}
}
Hidden [string] ResolvePublicClientId([string]$AppId) {
if(-not [String]::IsNullOrWhiteSpace($AppId)) { return $AppId }
# Match MSAL's app selection: Settings -> Entra app dropdown, then custom
# app id, then the default Microsoft Graph PowerShell public client.
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
try {
$entraApp = Get-EntraApp
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.ClientId)) {
return [string]$entraApp.ClientId
}
} catch { }
}
return "14d82eec-204b-4c2f-b7e8-296a70dab67e"
}
Hidden [string] ResolveTenantId([string]$TenantId) {
if(-not [String]::IsNullOrWhiteSpace($TenantId)) { return $TenantId }
# Shared identity config: the common Entra settings supply the tenant the same
# way they supply the app id. Get-EntraApp surfaces EntraCustomTenantId on
# custom-app rows; the built-in app rows have no TenantId property, which
# safely yields $null here.
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
try {
$entraApp = Get-EntraApp
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.TenantId)) {
return [string]$entraApp.TenantId
}
} catch { }
}
# 'organizations' = any work/school account; the real tenant id is recovered
# from the token's tid claim after sign-in.
return 'organizations'
}
# === Auth lifecycle ===
[PSCustomObject] Connect([hashtable]$Arguments) {
Write-Log "AuthenticationOAuth.Connect starting"
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud } else { Get-DefaultCloud }
$cloudEntry = Get-CloudByValue $cloudValue
$authority = $cloudEntry.AADAuthority
$graphHost = $cloudEntry.GraphHost
$defaultScope = "https://$graphHost/.default"
$resource = "https://$graphHost"
$tenantId = [string]$Arguments.TenantId
$clientId = [string]$Arguments.AppId
# Determine flow + run it. State stored in $cred is what Refresh() and
# GetAccessToken() use to re-acquire when the access token expires.
$cred = $null
$tokenResp = $null
$authMethod = $null
try {
if($Arguments.ContainsKey('Token') -and $Arguments.Token) {
$authMethod = 'BYO'
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
}
$tokenResp = [PSCustomObject]@{
access_token = [string]$Arguments.Token
expires_in = $null # unknown; parsed from JWT exp below
token_type = 'Bearer'
}
}
elseif( ($Arguments.ContainsKey('Browser') -and $Arguments.Browser) -or
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive -and
-not ($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -and
((Get-CacheObject "ShowUI") -eq $true)) ) {
# Browser (Authorization Code + PKCE, loopback redirect). Chosen for an
# explicit -Browser, or for -Interactive when a GUI is present (ShowUI);
# headless -Interactive keeps going to device code below.
$authMethod = 'AuthCode'
# Client id / tenant: explicit args win, else the common Entra settings
# (same app selection as MSAL - dropdown, custom app id, then the
# well-known Microsoft Graph PowerShell public client, which already
# has http://localhost registered).
$clientId = $this.ResolvePublicClientId($clientId)
$acTenant = $this.ResolveTenantId($tenantId)
$prompt = if($Arguments.Prompt) { [string]$Arguments.Prompt } else { [string](Get-SettingValue "OAuthPrompt") }
$loginHint = if($Arguments.LoginHint) { [string]$Arguments.LoginHint } else { [string](Get-SettingValue "OAuthLoginHint") }
$redirectPort = if($Arguments.RedirectPort) { [int]$Arguments.RedirectPort } else { [int](Get-SettingValue "OAuthRedirectPort") }
$timeoutSec = 600
try { $t = [int](Get-SettingValue "MSGraphInteractiveTimeoutSec"); if($t -gt 0) { $timeoutSec = $t } } catch { }
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
}
try {
$tokenResp = Invoke-OAuthAuthCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId `
-Scope "$defaultScope offline_access" -RedirectPort $redirectPort -Prompt $prompt -LoginHint $loginHint -TimeoutSec $timeoutSec
}
catch [System.OperationCanceledException] {
# The user clicked Cancel on the sign-in overlay. That is a decision,
# not a broken browser, so do not start a second (device code) login
# behind their back - let it out and leave the session signed out.
Write-Log "OAuth provider: browser sign-in cancelled by the user" 2
throw
}
catch {
# Browser unavailable (headless -Browser, no default browser, or the
# loopback port is blocked). Fall back to device code so sign-in can
# still complete. Refresh works identically for both (refresh_token).
Write-Log "OAuth provider: browser sign-in failed ($($_.Exception.Message)); falling back to device code" 2
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId -Scope "$defaultScope offline_access"
}
}
elseif(($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -or
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive)) {
if($Arguments.Interactive -and -not $Arguments.DeviceCode) {
# -Interactive reached here means no GUI was available for the
# browser flow above (headless), so use RFC 8628 device code -
# keeping the Connect-IntuneManagement -Interactive story working
# on every provider.
Write-Log "OAuth provider: -Interactive routed to device code flow (no GUI for browser login)"
}
$authMethod = 'DeviceCode'
$clientId = $this.ResolvePublicClientId($clientId)
if(-not $clientId) { throw "AppId is required for device code auth" }
# Tenant from the common Entra settings when not explicit (same
# resolution as the browser flow above).
$dcTenant = $this.ResolveTenantId($tenantId)
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
}
# offline_access so the response includes a refresh_token —
# that's what AcquireFromState uses for silent renewal.
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $dcTenant -ClientId $clientId -Scope "$defaultScope offline_access"
}
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity -and
-not ($Arguments.FederatedTokenFile -or $Arguments.FederatedToken)) {
$authMethod = 'IMDS'
if(-not $clientId -and $Arguments.ManagedIdentityClientId) {
$clientId = [string]$Arguments.ManagedIdentityClientId
}
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
}
$tokenResp = Invoke-OAuthIMDS -Resource $resource -ClientId $clientId
# IMDS responds with token_type / access_token / expires_in (string seconds);
# tenant_id is also included. Use the IMDS-reported tenant if our caller
# didn't supply one.
if(-not $tenantId -and $tokenResp.tenant_id) {
$tenantId = $tokenResp.tenant_id
$cred.TenantId = $tenantId
}
}
elseif($Arguments.FederatedTokenFile -or $Arguments.FederatedToken) {
$authMethod = 'Federated'
if(-not $tenantId) { throw "TenantId is required for federated credential auth" }
if(-not $clientId) { throw "AppId is required for federated credential auth" }
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
FederatedTokenFile = [string]$Arguments.FederatedTokenFile
FederatedToken = [string]$Arguments.FederatedToken
}
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
grant_type = 'client_credentials'
client_id = $clientId
scope = $defaultScope
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
}
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
$authMethod = 'Certificate'
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
if(-not $clientId) { throw "AppId is required for certificate auth" }
$cert = $this.ResolveCertificate($Arguments.Certificate, $null, $null)
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
Certificate = $cert
}
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
grant_type = 'client_credentials'
client_id = $clientId
scope = $defaultScope
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
}
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
$authMethod = 'Certificate'
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
if(-not $clientId) { throw "AppId is required for certificate auth" }
$cert = $this.ResolveCertificate($null, [string]$Arguments.CertificatePath, $Arguments.CertificatePassword)
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
Certificate = $cert
CertificatePath = [string]$Arguments.CertificatePath
CertificatePassword = $Arguments.CertificatePassword
}
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
grant_type = 'client_credentials'
client_id = $clientId
scope = $defaultScope
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
}
elseif($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
$authMethod = 'ClientSecret'
if(-not $tenantId) { throw "TenantId is required for client-secret auth" }
if(-not $clientId) { throw "AppId is required for client-secret auth" }
$secretPlain = if($Arguments.Secret -is [SecureString]) {
[System.Net.NetworkCredential]::new("", $Arguments.Secret).Password
} else {
[string]$Arguments.Secret
}
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
Secret = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret } else { ConvertTo-SecureString $secretPlain -AsPlainText -Force }
}
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
grant_type = 'client_credentials'
client_id = $clientId
client_secret = $secretPlain
scope = $defaultScope
}
}
elseif($Arguments.ContainsKey('Credential') -and $Arguments.Credential) {
$authMethod = 'Password'
if(-not $tenantId) { throw "TenantId is required for password auth" }
if(-not $clientId) { throw "AppId is required for password auth" }
$pscred = [PSCredential]$Arguments.Credential
$passwordPlain = $pscred.GetNetworkCredential().Password
$cred = [ordered]@{
AuthMethod = $authMethod
Cloud = $cloudValue
Authority = $authority
Resource = $resource
TenantId = $tenantId
ClientId = $clientId
Username = $pscred.UserName
Password = $pscred.Password
}
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
grant_type = 'password'
client_id = $clientId
username = $pscred.UserName
password = $passwordPlain
scope = "$defaultScope offline_access"
}
}
else {
throw "AuthenticationOAuth.Connect: no supported credential argument was provided. Pass -Secret, -Certificate, -CertificatePath, -ManagedIdentity, -FederatedTokenFile/-FederatedToken, -Credential, -DeviceCode, or -Token."
}
}
catch [System.OperationCanceledException] {
# An interactive user explicitly abandoned the flow. Do not publish the
# canonical AuthenticationFailed event: consumers use that event for real
# authentication faults (and may tear down/redraw an existing session).
Write-Log "OAuth provider Connect cancelled by the user (method: $authMethod)" 2
return $null
}
catch {
Write-LogError "OAuth provider Connect failed (method: $authMethod)" $_.Exception
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth Connect failed (method: $authMethod)" -Exception $_.Exception
return $null
}
if(-not $tokenResp -or -not $tokenResp.access_token) {
Write-Log "OAuth provider: token request returned no access_token (method: $authMethod)" 3
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth token request returned no access_token (method: $authMethod)"
return $null
}
# Compute expiry. Prefer expires_in (relative seconds; reliable across all
# flows). Fall back to JWT exp claim if expires_in is absent (BYO token).
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
if($tokenResp.expires_in) {
$secs = [int]$tokenResp.expires_in
$expiresAt = [DateTime]::UtcNow.AddSeconds($secs)
}
else {
try {
$jwt = Get-JWTtoken $tokenResp.access_token
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
$expiresAt = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp).UtcDateTime
}
} catch { }
}
# If TenantId wasn't supplied (BYO token, IMDS without tenant_id),
# extract it from the access_token's `tid` claim so multi-tenant
# routing (Get-GraphDomain, Save-TenantCloud) still works.
if(-not $tenantId) {
try {
$jwt = Get-JWTtoken $tokenResp.access_token
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) {
$tenantId = [string]$jwt.Payload.tid
$cred.TenantId = $tenantId
}
} catch { }
}
# Allocate a TokenId from the central registry so ids are globally unique
# across providers (MSAL/OAuth/MgGraph), not just within this provider.
$tokenId = Get-NextAuthTokenId
$entry = [ordered]@{
Id = $tokenId
AccessToken = [string]$tokenResp.access_token
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { $null }
ExpiresAt = $expiresAt
TenantId = $tenantId
ClientId = $clientId
AuthMethod = $authMethod
Cloud = $cloudValue
Resource = $resource
CredentialState = $cred
AcquiredAt = [DateTime]::UtcNow
}
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
# Opt-in cross-restart persistence for the browser flow: DPAPI-encrypt the
# refresh token when "Remember login" (OAuthCacheToken) is on. Only for the
# interactive browser method - service/BYO flows re-acquire from their own
# credentials and shouldn't leave a refresh token on disk.
if($authMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
[void](Save-OAuthTokenCache -Data @{
RefreshToken = $entry.RefreshToken
ClientId = $clientId
TenantId = $tenantId
Cloud = $cloudValue
Authority = $authority
Resource = $resource
AuthMethod = $authMethod
})
}
Write-Log "OAuth provider: acquired token (TokenId=$tokenId, method=$authMethod, tenant=$tenantId, expires=$($expiresAt.ToLocalTime().ToString('s')))"
# Persist per-tenant cloud memory so subsequent calls land on the same authority.
try {
if($tenantId) {
Save-TenantCloud -TenantId $tenantId -Cloud $cloudValue
Save-SettingStoreValue "" "LastLoggedOnCloud" $cloudValue
}
} catch {
Write-LogDebug "OAuth provider: Save-TenantCloud failed: $($_.Exception.Message)"
}
# Realign the active provider so subsequent Invoke-MSGraphAPI calls route here.
try {
$cur = Get-AuthProvider
if($cur -and $cur.Id -ne $this.Id) {
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because OAuth authentication succeeded"
Set-ActiveAuthProvider -Id $this.Id
}
} catch { }
# Register with the central token registry, which fires AuthenticatedNewToken
# with the canonical [IMAuthToken] payload (no longer fired directly here).
$token = Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue
return $token
}
[bool] Disconnect([int]$TokenId) {
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
Write-Log "OAuth provider: disconnected TokenId=$TokenId (tenant=$($entry.TenantId))"
# Unregister BEFORE dropping the backend entry so the disconnect snapshot can
# still hydrate via GetUserInfo. The registry fires AuthenticationUserDisconnected
# (and promotes a survivor default if needed).
Unregister-AuthToken -TokenId $TokenId
[AuthenticationOAuth]::Tokens.Remove($TokenId) | Out-Null
# Sign-out of a browser session also drops the persisted refresh token so a
# later launch doesn't silently resume the account the user just signed out of.
if($entry.AuthMethod -eq 'AuthCode') { Clear-OAuthTokenCache }
return $true
}
[bool] Refresh([int]$TokenId) {
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
try {
$newToken = $this.AcquireFromState($TokenId)
return [bool]$newToken
}
catch {
Write-LogError "OAuth provider: refresh failed for TokenId=$TokenId" $_.Exception
return $false
}
}
# Re-run whatever flow originally minted this entry, replacing the access
# token (and refresh_token, where applicable) in place. Fires
# AuthenticationTokenRefresh on success. Used both by the Refresh() public
# method and by GetAccessToken's preflight expiry check.
Hidden [string] AcquireFromState([int]$TokenId) {
return $this.AcquireFromState($TokenId, $null)
}
# $Claims carries a CAE claims challenge (from a Graph 401) into the /token
# request so the re-minted token satisfies the tenant's policy change.
Hidden [string] AcquireFromState([int]$TokenId, [string]$Claims) {
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
$cred = $entry.CredentialState
$defaultScope = "$($cred.Resource)/.default"
$tokenResp = $null
switch($cred.AuthMethod) {
'BYO' {
# BYO bearer can't be silently refreshed — caller must Connect again.
Write-Log "OAuth provider: BYO token (TokenId=$TokenId) cannot be refreshed automatically" 2
return $null
}
'IMDS' {
if($Claims) {
# IMDS / App Service token endpoints don't accept a claims
# parameter — a CAE challenge can't be satisfied here.
Write-Log "OAuth provider: managed identity tokens cannot satisfy a CAE claims challenge (TokenId=$TokenId)" 2
return $null
}
$tokenResp = Invoke-OAuthIMDS -Resource $cred.Resource -ClientId $cred.ClientId
}
'Federated' {
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
grant_type = 'client_credentials'
client_id = $cred.ClientId
scope = $defaultScope
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
}
'Certificate' {
$assertion = Get-OAuthClientAssertion -Certificate $cred.Certificate -ClientId $cred.ClientId -Authority $cred.Authority -TenantId $cred.TenantId
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
grant_type = 'client_credentials'
client_id = $cred.ClientId
scope = $defaultScope
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
client_assertion = $assertion
}
}
'ClientSecret' {
$secretPlain = [System.Net.NetworkCredential]::new("", $cred.Secret).Password
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
grant_type = 'client_credentials'
client_id = $cred.ClientId
client_secret = $secretPlain
scope = $defaultScope
}
}
'DeviceCode' {
# Silent-only here: never re-prompt with a new device code from a
# refresh path. No refresh token → the 401/expiry surfaces and the
# user re-runs Connect with -DeviceCode explicitly.
if(-not $entry.RefreshToken) {
Write-Log "OAuth provider: device-code token (TokenId=$TokenId) has no refresh token - run Connect-IntuneManagement -DeviceCode again" 2
return $null
}
$dcTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $dcTenant -Claims $Claims -Body @{
grant_type = 'refresh_token'
client_id = $cred.ClientId
refresh_token = $entry.RefreshToken
scope = "$defaultScope offline_access"
}
}
'AuthCode' {
# Browser (auth-code) refresh is silent - the refresh_token grant,
# identical to DeviceCode. No browser/redirect needed. Missing refresh
# token means the user must sign in again.
if(-not $entry.RefreshToken) {
Write-Log "OAuth provider: browser token (TokenId=$TokenId) has no refresh token - sign in again" 2
return $null
}
$acTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $acTenant -Claims $Claims -Body @{
grant_type = 'refresh_token'
client_id = $cred.ClientId
refresh_token = $entry.RefreshToken
scope = "$defaultScope offline_access"
}
}
'Password' {
# Prefer refresh_token grant if we got one; falls back to ROPC re-prompt.
if($entry.RefreshToken) {
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
grant_type = 'refresh_token'
client_id = $cred.ClientId
refresh_token = $entry.RefreshToken
scope = "$defaultScope offline_access"
}
}
else {
$passwordPlain = [System.Net.NetworkCredential]::new("", $cred.Password).Password
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
grant_type = 'password'
client_id = $cred.ClientId
username = $cred.Username
password = $passwordPlain
scope = "$defaultScope offline_access"
}
}
}
default {
throw "OAuth provider: unknown AuthMethod '$($cred.AuthMethod)' on TokenId=$TokenId"
}
}
if(-not $tokenResp -or -not $tokenResp.access_token) {
Write-Log "OAuth provider: refresh request returned no access_token for TokenId=$TokenId" 3
return $null
}
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
if($tokenResp.expires_in) {
$expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in)
}
$entry.AccessToken = [string]$tokenResp.access_token
if($tokenResp.refresh_token) { $entry.RefreshToken = [string]$tokenResp.refresh_token }
$entry.ExpiresAt = $expiresAt
$entry.AcquiredAt = [DateTime]::UtcNow
[AuthenticationOAuth]::Tokens[$TokenId] = $entry
Write-LogDebug "OAuth provider: refreshed TokenId=$TokenId (method=$($cred.AuthMethod), expires=$($expiresAt.ToLocalTime().ToString('s')))"
# Re-persist the rotated refresh token for the browser flow when caching is on.
if($cred.AuthMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
[void](Save-OAuthTokenCache -Data @{
RefreshToken = $entry.RefreshToken
ClientId = $cred.ClientId
TenantId = $entry.TenantId
Cloud = $entry.Cloud
Authority = $cred.Authority
Resource = $cred.Resource
AuthMethod = 'AuthCode'
})
}
Update-AuthToken -TokenId $TokenId
return $entry.AccessToken
}
# CAE entry point — Invoke-MSGraphAPI calls this when Graph answers 401 with
# a WWW-Authenticate claims challenge. Re-mints the token with the challenge
# attached; returns the new access token, or $null when the flow can't
# satisfy claims (BYO, managed identity, no refresh token).
[string] AcquireWithClaims([int]$TokenId, [string]$ClaimsChallenge) {
return $this.AcquireFromState($TokenId, $ClaimsChallenge)
}
# Satisfy a CAE claims challenge by re-running the credential flow with the claims
# attached to the /token body. Returns $null when the flow can't satisfy the claims
# (e.g. BYO / managed identity). OAuth has no interactive browser escalation, so
# $AllowInteractive is not used.
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
return $this.AcquireWithClaims($TokenId, $ClaimsChallenge)
}
# === Token retrieval ===
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
if($TokenId -le 0) {
# Caller didn't pin a TokenId; pick the most recently-acquired entry.
if([AuthenticationOAuth]::Tokens.Count -eq 0) { return $null }
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
$TokenId = $latest.Id
}
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
# 5-minute slack so we don't hand out a token that expires mid-request.
if($entry.ExpiresAt -le [DateTime]::UtcNow.AddMinutes(5)) {
$refreshed = $this.AcquireFromState($TokenId)
if($refreshed) { return $refreshed }
# Refresh failed; surface the stale token rather than $null and let
# Invoke-MSGraphAPI handle the inevitable 401 — same behavior as MSAL.
}
return [string]$entry.AccessToken
}
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) {
return [datetime]::MaxValue
}
# ExpiresAt is stored UTC; return LOCAL to match the provider contract (MSAL
# returns ExpiresOn.LocalDateTime) and the local-time comparisons in
# Invoke-MSGraphAPI / Test-DefaultTokenExpired. Returning raw UTC made callers
# in ahead-of-UTC timezones see a just-issued token as already expired.
return [AuthenticationOAuth]::Tokens[$TokenId].ExpiresAt.ToLocalTime()
}
# === Display / picker data ===
[PSCustomObject] GetUserInfo([int]$TokenId) {
if($TokenId -le 0 -and [AuthenticationOAuth]::Tokens.Count -gt 0) {
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
$TokenId = $latest.Id
}
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
$upn = $null; $userId = $null; $appName = $null
try {
$jwt = Get-JWTtoken $entry.AccessToken
if($jwt -and $jwt.Payload) {
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
elseif($jwt.Payload.unique_name) { [string]$jwt.Payload.unique_name }
else { $null }
$userId = if($jwt.Payload.oid) { [string]$jwt.Payload.oid } else { $null }
$appName = if($jwt.Payload.app_displayname) { [string]$jwt.Payload.app_displayname } else { $null }
}
} catch { }
# Map AuthMethod to the cross-provider taxonomy (Interactive / ClientCredential / BYO / ...).
$authType = switch ($entry.AuthMethod) {
'BYO' { 'BYO' }
'DeviceCode' { 'Interactive' }
'AuthCode' { 'Interactive' }
'IMDS' { 'ManagedIdentity' }
'Federated' { 'WorkloadFederation' }
'Certificate' { 'ClientCredential' }
'ClientSecret' { 'ClientCredential' }
'Password' { 'Password' }
default { [string]$entry.AuthMethod }
}
# Caller-friendly default for headless flows: when there's no UPN
# (app-only token), display the app id.
$displayName = if($upn) { $upn } else { $entry.ClientId }
# Tenant display name — one /organization GET per tenant per session,
# mirroring AuthenticationMgGraph.TenantNameCache. App-only tokens
# without Organization.Read.All fail the lookup; the $null result is
# cached too so it isn't retried on every refresh event.
$tenantName = $null
if($entry.TenantId) {
if([AuthenticationOAuth]::TenantNameCache.ContainsKey($entry.TenantId)) {
$tenantName = [AuthenticationOAuth]::TenantNameCache[$entry.TenantId]
}
else {
$tenantName = Get-OAuthTenantOrganizationName -Resource $entry.Resource -AccessToken $entry.AccessToken
[AuthenticationOAuth]::TenantNameCache[$entry.TenantId] = $tenantName
}
}
return [PSCustomObject]@{
Provider = $this.Id
DisplayName = $displayName
UPN = $upn
UserId = $userId
TenantId = $entry.TenantId
TenantName = $tenantName
AppId = $entry.ClientId
AppName = $appName
AuthType = $authType
ExpiresOn = $entry.ExpiresAt.ToLocalTime()
}
}
[PSCustomObject[]] GetCachedAccounts() {
# Pure-headless: surface the token cache as the account list. Each entry
# represents one Connect() call within the session.
$rows = @()
foreach($entry in [AuthenticationOAuth]::Tokens.Values) {
$upn = $null
try {
$jwt = Get-JWTtoken $entry.AccessToken
if($jwt -and $jwt.Payload) {
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
else { $null }
}
} catch { }
$rows += [PSCustomObject]@{
Provider = $this.Id
Username = if($upn) { $upn } else { $entry.ClientId }
UserId = $null
TenantId = $entry.TenantId
Native = $entry
}
}
return [PSCustomObject[]]$rows
}
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
# Tenant enumeration costs an extra Graph call (/tenants or /me/memberOf
# /transitive). Headless callers usually know which tenant they want;
# leave this as a v2 enhancement.
return [PSCustomObject[]]@()
}
# Resolve a -Certificate argument (thumbprint string OR X509Certificate2 OR
# path-to-pfx) into a usable X509Certificate2. Reuses the MSAL provider's
# resolver where available so behavior stays identical.
Hidden [System.Security.Cryptography.X509Certificates.X509Certificate2] ResolveCertificate($CertObject, [string]$Path, $Password) {
if($CertObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
return $CertObject
}
if($CertObject) {
# Treat as thumbprint string — search Cert: stores the same way MSAL does.
if(Get-Command Resolve-MSALCertificate -ErrorAction SilentlyContinue) {
$cert = Resolve-MSALCertificate $CertObject
if($cert) { return $cert }
}
# Fallback: walk Cert:\CurrentUser\My then Cert:\LocalMachine\My.
$thumb = ([string]$CertObject).Replace(' ', '').ToUpperInvariant()
foreach($store in @('Cert:\CurrentUser\My', 'Cert:\LocalMachine\My')) {
$hit = Get-ChildItem $store -ErrorAction SilentlyContinue | Where-Object Thumbprint -EQ $thumb | Select-Object -First 1
if($hit) { return $hit }
}
throw "Could not find certificate with thumbprint '$CertObject' in CurrentUser\My or LocalMachine\My"
}
if($Path) {
if(-not (Test-Path $Path)) { throw "Certificate file not found: $Path" }
return Get-PfxCertificate -FilePath $Path -Password $Password -ErrorAction Stop
}
throw "ResolveCertificate: neither object nor path was supplied"
}
}
+207
View File
@@ -0,0 +1,207 @@
#ImportOrder 25
# Base class for authentication providers.
#
# Naming convention: concrete providers are named Authentication<Backend>
# (AuthenticationMSAL, AuthenticationMgGraph, AuthenticationAz). The base class
# stays AuthenticationProvider — there's only one of those.
#
# Three concrete providers ship: AuthenticationMSAL (MSAL.NET), AuthenticationMgGraph
# (Microsoft.Graph SDK) and AuthenticationOAuth (pure PowerShell for automation).
#
# Concrete providers override what they support. Methods that MUST be overridden
# throw NotImplemented when called on the base; optional methods return safe defaults
# so a provider that doesn't support a feature simply reports back $false / $null.
#
# Return shapes for the *Info / *Accounts / *Tenants methods are uniform across all
# providers — that's the whole point of the abstraction. Consumers (Invoke-MSGraphAPI,
# profile dialog, account picker) read these uniform shapes directly. Invoke-MSGraphAPI
# resolves a call's OWNING provider by TokenId and gets the bearer from it (or lets the
# provider run the request), so MSAL / MgGraph / OAuth tokens can all be live at once.
#
# === Required capabilities (contract) ===
# Every concrete provider MUST support and prove out these auth modes:
# * Interactive login (SupportsInteractive = $true)
# * App with client secret (SupportsClientSecret = $true)
# * App with certificate (SupportsCertificate = $true)
# Setting any of these to $false on a concrete provider is a contract violation —
# the abstraction assumes consumers can pick any of the three.
#
# === Recommended capabilities ===
# Providers SHOULD also support, where the backend allows it:
# * Identity Provider login (SupportsIdentityProvider = $true)
# Covers managed identity, workload identity federation, and federated
# credential assertions. Not required because Connect-IntuneManagement
# doesn't yet expose those parameter sets, but expected for full coverage.
class AuthenticationProvider {
# Identity
[string]$Id
[string]$DisplayName
# Capability flags. The profile UI reads these to enable / disable buttons and the
# connect facade routes only to providers that support the requested mode.
[bool]$SupportsMultiTenant = $true
[bool]$SupportsRefresh = $true
[bool]$SupportsForget = $true
[bool]$SupportsCachedUsers = $true
# Required by contract. See block comment above.
[bool]$SupportsInteractive = $true
[bool]$SupportsClientSecret = $true
[bool]$SupportsCertificate = $true
# Recommended. Managed identity / workload identity federation / federated
# credential. Off by default — concrete providers opt in when implemented.
[bool]$SupportsIdentityProvider = $false
# Optional. Provider accepts an externally-issued bearer token (no auth flow).
[bool]$SupportsBYOToken = $false
# Optional. Provider can satisfy a CAE (Continuous Access Evaluation) claims
# challenge - a Graph 401 carrying a WWW-Authenticate claims="..." parameter - by
# re-minting the token via GetClaimsToken(). Providers whose SDK handles CAE
# internally, or that can't re-mint (pure BYO), leave this $false and the caller
# surfaces the 401 unchanged.
[bool]$SupportsClaimsChallenge = $false
# Optional. The provider is wired directly into the module's built-in
# Connect-EntraEnvironment / Connect-IntuneManagement entry points (the original
# pre-abstraction MSAL path). Callers that want that rich handling (sovereign-cloud
# -Cloud selection, ForceRefresh by TokenId, ...) drive such a provider through those
# functions instead of the generic Connect()/Refresh() hop, keeping behaviour and
# stack traces identical. Providers whose logic lives entirely in their own Connect()
# / Refresh() leave this $false.
[bool]$UsesBuiltInConnectPath = $false
# Optional. Provider can launch an interactive admin/user consent prompt for the
# app's delegated scopes (MSAL: Start-MSALConsentPrompt). The profile UI shows a
# "Request consent" action only when this is $true.
[bool]$SupportsConsentPrompt = $false
# The provider answers every Graph request itself through InvokeWebRequest,
# even though GetAccessToken returned a bearer. Off for the real providers -
# a bearer means "send it over HTTPS". On for a provider whose backend is not
# Graph at all (the offline mock tenant serves canned data from disk).
[bool]$RoutesAllRequests = $false
# Always Graph for now; -Resource is plumbed through so future phases can mint
# tokens for other audiences (Key Vault, Storage, etc.) without API changes.
[string]$DefaultResource = "https://graph.microsoft.com"
# Called once at module init for provider-specific setup (DLL loads, settings).
# Default is a no-op.
[void] Initialize() { }
# === Auth lifecycle ===
# Must override:
[PSCustomObject] Connect([hashtable]$Arguments) {
throw "Provider '$($this.Id)' does not implement Connect"
}
# Optional (return $false if not supported):
[bool] Disconnect([int]$TokenId) { return $false }
[bool] ForgetAccount([string]$AccountIdentifier) { return $false }
[bool] Refresh([int]$TokenId) { return $false }
# List the tenants the signed-in account can reach, for a tenant picker or a
# pre-flight check before Connect -TenantId. Microsoft Graph has no API for
# this: findTenantInformationByTenantId resolves ONE tenant you already know,
# and the multi-tenant-organization APIs only cover a configured MTO. The only
# general source is Azure Resource Manager's /tenants, which needs a token for
# a different audience - so a provider can implement this only if it can mint
# one for the same account.
#
# Return $null when the provider cannot enumerate (the default). Otherwise
# return @{ Tenants = <rows>; ConsentMissing = <bool>; Message = <string> } so
# the caller can tell "no tenants" from "the app lacks the permission".
[PSCustomObject] GetAccessibleTenants([int]$TokenId) { return $null }
[PSCustomObject] SwitchTenant([int]$TokenId, [string]$NewTenantId) { return $null }
# Called once at app startup (AppInitialized event) for the active provider only.
# Implementations should attempt a SILENT (non-interactive) sign-in if their backend
# has persisted credentials on disk. Return $true if the user is now signed in,
# $false otherwise. Default is no-op — MSAL has its own cross-session refresh path
# via $script:MSALDefaultToken on startup, so it doesn't need this hook.
[bool] TryResumeSession() { return $false }
# Optional. Cheap SILENT ambient-session refresh, invoked on view activation to keep
# the default token fresh without ever prompting. Providers that have no such
# mechanism - or where a silent auth here could hijack the active session - leave the
# base no-op. (MSAL refreshes via Connect-EntraEnvironment -ForceSilent.)
[void] RefreshAmbientSession() { }
# === Token retrieval ===
# Must override. -Resource is informational for providers that mint per-audience
# tokens; today only Graph is required.
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
throw "Provider '$($this.Id)' does not implement GetAccessToken"
}
# Optional. Returns DateTime.MaxValue when expiry is unknown (callers should treat
# MaxValue as "no preflight refresh needed").
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
return [datetime]::MaxValue
}
# Optional. Re-mint an access token to satisfy a CAE claims challenge returned by
# the resource (Graph 401 -> WWW-Authenticate claims="..."). Implemented only by
# providers with SupportsClaimsChallenge = $true; the base returns $null so a
# provider that can't satisfy the challenge simply lets the 401 surface.
# $Resource - target audience (informational; Graph today)
# $ClaimsChallenge - the claims value parsed from the 401 challenge
# $AllowInteractive - caller context: $true when a UI is present and this is NOT a
# nested auth-flow call, so the provider MAY prompt if it can't
# satisfy the challenge silently; $false forces silent-only.
# Returns the new access token, or $null if the challenge couldn't be satisfied.
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
return $null
}
# Optional. Providers whose backend SDK owns the HTTP pipeline and won't hand out a
# raw bearer token override this to run the request themselves and return a response
# object shaped like Invoke-WebRequest's (StatusCode / Headers / Content /
# RawContentLength). Return $null to signal "I don't route requests - use the raw
# access token from GetAccessToken via Invoke-WebRequest". Base default: $null.
[object] InvokeWebRequest([string]$Url, [string]$Method, [object]$Body, [hashtable]$Headers) {
return $null
}
# === Display / picker data (uniform shapes for UI consumption) ===
# Returns a PSCustomObject with these properties (or $null if unknown):
# Provider - this.Id
# DisplayName - user's display name
# UPN - user principal name (login id)
# UserId - tenant-scoped object id
# TenantId - GUID
# TenantName - organization display name
# AppId - Entra app client id
# AppName - Entra app display name
# AuthType - "Interactive" | "ClientCredential" | "BYO" | "DeviceCode" | ...
# ExpiresOn - DateTime (local) or $null
[PSCustomObject] GetUserInfo([int]$TokenId) { return $null }
# Returns PSCustomObject[] with these per-row properties:
# Provider, Username, UserId, TenantId, Native (provider-opaque)
[PSCustomObject[]] GetCachedAccounts() { return [PSCustomObject[]]@() }
# Returns PSCustomObject[] with these per-row properties:
# Provider, TenantId, TenantName, Native
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) { return [PSCustomObject[]]@() }
# Returns Name/Value rows describing the provider's native session/context for the
# profile "Session Info" inspector (MSAL: AuthenticationResult fields; MgGraph:
# Get-MgContext properties). Base default: no rows.
[PSCustomObject[]] GetSessionInfoRows() { return [PSCustomObject[]]@() }
# Decoded id-token JWT ({Header, Payload}) for the profile popup's "Id Token"
# inspector, or $null when the provider doesn't surface an id token. The UI shows
# the Id Token button only when this returns non-null, so non-MSAL providers hide
# it automatically. Keeps raw-JWT knowledge inside the owning provider.
[object] GetIdTokenJwt([int]$TokenId) { return $null }
# === Provider-specific UI hook ===
# MSAL adds "MSAL Token / Access Token / ID Token" inspector buttons here, for
# example. Returns a WPF element to splice into the profile popup, or $null.
[object] BuildLoginMenu([object]$Window) { return $null }
}
+382
View File
@@ -0,0 +1,382 @@
#ImportOrder 30
class CompareProviderBase
{
[string] $Name
[string] $Value
[string] $OptionsXaml
[string[]] $RemoveProperties = @()
[bool] $IgnoreGroups = $false
# Skip objects that exist on only one side. Source = the reverse pass
# (objects only in the counterpart set); Destination = the forward pass
# (objects whose looked-up counterpart is missing). Same semantics as the
# original project's Skip Missing Source/Destination Policies checkboxes.
[bool] $SkipMissingSourcePolicies = $false
[bool] $SkipMissingDestinationPolicies = $false
CompareProviderBase([string]$name, [string]$value, [string]$optionsXaml)
{
$this.Name = $name
$this.Value = $value
$this.OptionsXaml = $optionsXaml
}
[object[]] GetComparePairs([object[]]$groups) { return @() }
[bool] Validate() { return $true }
[void] SaveSettings() {}
[string] ToString() { return $this.Name }
}
class CompareExportFilesProvider : CompareProviderBase
{
[string] $ExportPath
[string] $NameFilter
CompareExportFilesProvider() : base(
"Exported Files with Intune Objects (Id)",
"export",
"CompareExportOptions"
) {}
[object[]] GetComparePairs([object[]]$groups)
{
$pairs = @()
foreach($group in $groups)
{
foreach($policyType in $group.PolicyTypes)
{
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
if(-not [IO.Directory]::Exists($folder))
{
Write-Log "Folder '$folder' not found. Skipping." 2
continue
}
Save-SettingStoreValue "" "LastUsedFullPath" $folder
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
$pairedFileIds = @()
foreach($fileObj in $fileObjs)
{
$objName = $fileObj.Name
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
if(-not $fileObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
$intuneObj = $intuneObjs | Where-Object { $_.ID -eq $fileObj.ID }
if($intuneObj) { $policyType.GetFullObject($intuneObj) | Out-Null }
elseif($this.SkipMissingDestinationPolicies) { continue }
else { Write-Log "Object '$objName' with id $($fileObj.ID) not found in Intune. Deleted?" 2 }
$pairedFileIds += [string]$fileObj.ID
$pairs += [PSCustomObject]@{
Name = $objName
Id = $fileObj.ID
PolicyType = $policyType
SaveFolder = $folder
Policy1 = $intuneObj
Policy2 = $fileObj
}
}
if(-not $this.SkipMissingSourcePolicies)
{
# Objects that exist in Intune but were never exported.
foreach($intuneObj in $intuneObjs)
{
if([string]$intuneObj.ID -in $pairedFileIds) { continue }
$objName = $intuneObj.Name
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
Write-Log "Object '$objName' with id $($intuneObj.ID) exists in Intune but has no exported file. New object?" 2
$pairs += [PSCustomObject]@{
Name = $objName
Id = $intuneObj.ID
PolicyType = $policyType
SaveFolder = $folder
Policy1 = $intuneObj
Policy2 = $null
}
}
}
}
}
return $pairs
}
[void] SaveSettings()
{
# No persisted options: the "Compare\ExportPath" write that used to live here
# was never read back anywhere (dead since the port).
}
}
class CompareIntuneWithExportProvider : CompareProviderBase
{
[string] $ExportPath
[string] $NameFilter
CompareIntuneWithExportProvider() : base(
"Intune Objects with Exported Files (Name)",
"IntuneWithExport",
"CompareExportOptions"
) {}
[object[]] GetComparePairs([object[]]$groups)
{
$pairs = @()
foreach($group in $groups)
{
foreach($policyType in $group.PolicyTypes)
{
$folder = [IO.Path]::Combine($this.ExportPath, $policyType.Folder)
if(-not [IO.Directory]::Exists($folder))
{
Write-Log "Folder '$folder' not found. Skipping." 2
continue
}
Save-SettingStoreValue "" "LastUsedFullPath" $folder
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
$fileObjs = @(Get-PoliciesFromFolder -Path $folder -PolicyTypes @($policyType))
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID | Where-Object { $_.PolicyType.ID -eq $policyType.ID })
$pairedFileNames = @()
foreach($intuneObj in $intuneObjs)
{
$objName = $intuneObj.Name
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
$fileObj = $fileObjs | Where-Object { $_.Name -eq $objName }
if(($fileObj | Measure-Object).Count -gt 1)
{
Write-Log "Multiple file objects with name '$objName'. Skipping." 2
continue
}
if($fileObj) {
$policyType.GetFullObject($intuneObj) | Out-Null
$pairedFileNames += [string]$objName
}
elseif($this.SkipMissingDestinationPolicies) { continue }
else { Write-Log "Object '$objName' with id $($intuneObj.ID) not found in exported folder. New object?" 2 }
$pairs += [PSCustomObject]@{
Name = $objName
Id = $intuneObj.ID
PolicyType = $policyType
SaveFolder = $folder
Policy1 = $intuneObj
Policy2 = $fileObj
}
}
if(-not $this.SkipMissingSourcePolicies)
{
# Exported files with no matching Intune object.
foreach($fileObj in $fileObjs)
{
$objName = $fileObj.Name
if([string]$objName -in $pairedFileNames) { continue }
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
Write-Log "File object '$objName' has no matching Intune object. Deleted?" 2
$pairs += [PSCustomObject]@{
Name = $objName
Id = $fileObj.ID
PolicyType = $policyType
SaveFolder = $folder
Policy1 = $null
Policy2 = $fileObj
}
}
}
}
}
return $pairs
}
[void] SaveSettings()
{
# No persisted options: the "Compare\ExportPath" write that used to live here
# was never read back anywhere (dead since the port).
}
}
class CompareNamedObjectsProvider : CompareProviderBase
{
[string] $SourcePattern
[string] $ComparePattern
[string] $SavePath
[string[]] $RemoveProperties = @("Id")
CompareNamedObjectsProvider() : base(
"Named Objects in Intune",
"name",
"CompareNamedOptions"
)
{
$this.RemoveProperties = @("Id")
}
[object[]] GetComparePairs([object[]]$groups)
{
if(-not $this.SourcePattern -or -not $this.ComparePattern)
{
throw "Both source and compare name patterns must be specified"
}
$outputFolder = $this.SavePath
if(-not $outputFolder) { $outputFolder = [Environment]::GetFolderPath("MyDocuments") }
$pairs = @()
foreach($group in $groups)
{
Write-Status "Compare $($group.Title) objects" -Force -SkipLog
$intuneObjs = @(Get-GraphPolicies -PolicyGroup $group.ID)
foreach($intuneObj in ($intuneObjs | Where-Object { $_.Name -imatch [regex]::Escape($this.SourcePattern) }))
{
$sourceName = $intuneObj.Name
$compareName = $sourceName -ireplace [regex]::Escape($this.SourcePattern), $this.ComparePattern
$compareObj = $intuneObjs | Where-Object { $_.Name -eq $compareName -and $_.Object.'@OData.Type' -eq $intuneObj.Object.'@OData.Type' }
if(($compareObj | Measure-Object).Count -gt 1)
{
Write-Log "Multiple objects named '$compareName'. Skipping." 2
continue
}
if($compareObj)
{
$intuneObj.PolicyType.GetFullObject($intuneObj) | Out-Null
$compareObj.PolicyType.GetFullObject($compareObj) | Out-Null
}
$pairs += [PSCustomObject]@{
Name = $sourceName
Id = $intuneObj.ID
PolicyType = $intuneObj.PolicyType
SaveFolder = $outputFolder
Policy1 = $intuneObj
Policy2 = $compareObj
}
}
}
return $pairs
}
[void] SaveSettings()
{
# No persisted options: the CompareSource / CompareWith / SavePath writes that
# used to live here were never read back anywhere (dead since the port).
}
}
class CompareExportedFoldersProvider : CompareProviderBase
{
[string] $SourcePath
[string] $ComparePath
[string] $NameFilter
CompareExportedFoldersProvider() : base(
"Files in Exported Folders",
"exportedFolders",
"CompareExportedFilesOptions"
) {}
[object[]] GetComparePairs([object[]]$groups)
{
if(-not $this.SourcePath -or -not $this.ComparePath)
{
throw "Both source and compare folders must be specified"
}
if(-not [IO.Directory]::Exists($this.SourcePath))
{
throw "Source folder '$($this.SourcePath)' does not exist"
}
if(-not [IO.Directory]::Exists($this.ComparePath))
{
throw "Compare folder '$($this.ComparePath)' does not exist"
}
$pairs = @()
foreach($group in $groups)
{
foreach($policyType in $group.PolicyTypes)
{
$folderSrc = [IO.Path]::Combine($this.SourcePath, $policyType.Folder)
$folderCmp = [IO.Path]::Combine($this.ComparePath, $policyType.Folder)
if(-not [IO.Directory]::Exists($folderSrc)) { Write-Log "Source folder '$folderSrc' not found. Skipping." 2; continue }
Save-SettingStoreValue "" "LastUsedFullPath" $folderSrc
Write-Status "Compare $($policyType.Title)" -Force -SkipLog
$srcObjs = @(Get-PoliciesFromFolder -Path $folderSrc -PolicyTypes @($policyType))
$cmpObjs = @()
if([IO.Directory]::Exists($folderCmp))
{
$cmpObjs = @(Get-PoliciesFromFolder -Path $folderCmp -PolicyTypes @($policyType))
}
$addedIds = @()
foreach($srcObj in $srcObjs)
{
$objName = $srcObj.Name
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
if(-not $srcObj.ID) { Write-Log "File '$objName' has no Id. Skipping." 2; continue }
$cmpObj = $cmpObjs | Where-Object { $_.ID -eq $srcObj.ID }
$addedIds += $srcObj.ID
if(-not $cmpObj -and $this.SkipMissingDestinationPolicies) { continue }
$pairs += [PSCustomObject]@{
Name = $objName
Id = $srcObj.ID
PolicyType = $policyType
SaveFolder = $folderSrc
Policy1 = $srcObj
Policy2 = $cmpObj
}
}
foreach($cmpObj in $cmpObjs)
{
if($this.SkipMissingSourcePolicies) { continue }
if($cmpObj.ID -in $addedIds) { continue }
$objName = $cmpObj.Name
if($this.NameFilter -and $objName -notmatch [RegEx]::Escape($this.NameFilter)) { continue }
$pairs += [PSCustomObject]@{
Name = $objName
Id = $cmpObj.ID
PolicyType = $policyType
SaveFolder = $folderSrc
Policy1 = $null
Policy2 = $cmpObj
}
}
}
}
return $pairs
}
[void] SaveSettings()
{
# No persisted options: the SourcePath / ComparePath writes that used to live
# here were never read back anywhere (dead since the port).
}
}
+48
View File
@@ -0,0 +1,48 @@
#ImportOrder 31
class CompareOutputProviderBase
{
[string] $Name
[string] $Value
[string] $Extension
CompareOutputProviderBase([string]$name, [string]$value, [string]$extension)
{
$this.Name = $name
$this.Value = $value
$this.Extension = $extension
}
[string] FormatRows([object[]]$rows, [string[]]$props) { return "" }
[string] ToString() { return $this.Name }
}
class CompareCSVOutputProvider : CompareOutputProviderBase
{
[string] $Delimiter = ";"
CompareCSVOutputProvider() : base("CSV", "csv", "csv")
{
$this.Delimiter = ";"
}
[string] FormatRows([object[]]$rows, [string[]]$props)
{
$selected = @($rows | Select-Object -Property $props)
if($this.Delimiter -and $this.Delimiter.Length -eq 1)
{
return ($selected | ConvertTo-Csv -NoTypeInformation -Delimiter ([char]$this.Delimiter)) -join [System.Environment]::NewLine
}
return ($selected | ConvertTo-Csv -NoTypeInformation) -join [System.Environment]::NewLine
}
}
class CompareJsonOutputProvider : CompareOutputProviderBase
{
CompareJsonOutputProvider() : base("JSON", "json", "json") {}
[string] FormatRows([object[]]$rows, [string[]]$props)
{
return $rows | Select-Object -Property $props | ConvertTo-Json -Depth 20
}
}
+73
View File
@@ -0,0 +1,73 @@
#ImportOrder 29
# Self-registration registry for the compare subsystem, mirroring
# [DocumentationRegistry]. Replaces the hardcoded $script:compare* arrays that
# Initialize-CompareModule used to build - which only the WPF AppInitialized
# handler ever ran, so in Avalonia mode the compare combos came up empty.
# Providers, output providers, and comparison types now register once at module
# load (Internal/Compare.ps1) so BOTH UI backends see the same catalog, and the
# documentation subsystem self-registers its own "doc" comparison type instead
# of Compare.ps1 reaching across with a Get-Command probe.
#
# Loaded at #ImportOrder 29 - before CompareClasses.ps1 (30) and
# CompareOutputClasses.ps1 (31) - so the provider/output classes it stores are
# already defined by the time Internal/Compare.ps1 registers instances.
#
# Dedupe is by .Value (a provider/output/type key), matching the
# DocumentationRegistry replace-by-identity semantics so Import-Module -Force
# re-registration never accumulates duplicates.
class CompareRegistry {
static [System.Collections.Generic.List[object]] $Providers = [System.Collections.Generic.List[object]]::new()
static [System.Collections.Generic.List[object]] $OutputProviders = [System.Collections.Generic.List[object]]::new()
static [System.Collections.Generic.List[PSCustomObject]] $ComparisonTypes = [System.Collections.Generic.List[PSCustomObject]]::new()
# ---- Compare providers (CompareProviderBase subclasses) ----
static [void] RegisterProvider([object]$Provider) {
if (-not $Provider.Value) { throw "Compare provider must have a Value" }
$existing = [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Provider.Value }
if ($existing) { [CompareRegistry]::Providers.Remove($existing) | Out-Null }
[CompareRegistry]::Providers.Add($Provider)
}
static [object] FindProvider([string]$Value) {
return [CompareRegistry]::Providers | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
}
# ---- Output providers (CompareOutputProviderBase subclasses) ----
static [void] RegisterOutputProvider([object]$Provider) {
if (-not $Provider.Value) { throw "Compare output provider must have a Value" }
$existing = [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Provider.Value }
if ($existing) { [CompareRegistry]::OutputProviders.Remove($existing) | Out-Null }
[CompareRegistry]::OutputProviders.Add($Provider)
}
static [object] FindOutputProvider([string]$Value) {
return [CompareRegistry]::OutputProviders | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
}
static [object] FindOutputProviderByExtension([string]$Extension) {
return [CompareRegistry]::OutputProviders | Where-Object { $_.Extension -eq $Extension } | Select-Object -First 1
}
# ---- Comparison types (PSCustomObject: Name, Value, [Compare], [RemoveProperties]) ----
static [void] RegisterComparisonType([PSCustomObject]$Type) {
if (-not $Type.Value) { throw "Comparison type must have a Value" }
$existing = [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Type.Value }
if ($existing) { [CompareRegistry]::ComparisonTypes.Remove($existing) | Out-Null }
[CompareRegistry]::ComparisonTypes.Add($Type)
}
static [PSCustomObject] FindComparisonType([string]$Value) {
return [CompareRegistry]::ComparisonTypes | Where-Object { $_.Value -eq $Value } | Select-Object -First 1
}
# Clear every collection. Called once at the top of the module-load
# registration in Internal/Compare.ps1 so Import-Module -Force starts clean
# (the static initializers above only run on first type load; the type is
# cached across -Force reimports).
static [void] Reset() {
[CompareRegistry]::Providers.Clear()
[CompareRegistry]::OutputProviders.Clear()
[CompareRegistry]::ComparisonTypes.Clear()
}
}
+92
View File
@@ -0,0 +1,92 @@
#ImportOrder 1
class ViewObjectBase
{
Hidden [String]$_ID = $null
Hidden [String]$_Title = $null
Hidden [String]$_Description = $null
Hidden [String]$_AuthenticaionObject = $null
Hidden [Boolean]$_HideMenu = $false
Hidden [Object]$_ViewPanel = $null
Hidden [Guid]$_SessionId = [Guid]::Empty
Hidden [Boolean]$_AddToMenu = $true
# When true, the top-bar Views menu surfaces this view's items as a
# submenu beneath the view entry — clicking a child both activates the
# parent view and selects that item in the left nav. Default off because
# most views (IntuneManagement with ~50 policy types) would balloon the
# Views menu past usability.
Hidden [Boolean]$_ExpandInViewsMenu = $false
ViewObjectBase()
{
if($this.GetType().Name -eq "ViewObjectBase") {
throw "Abstract class. Object cannot be created"
}
elseif($script:SingletonObjects.ContainsKey($this.GetType().Name) -eq $true) {
throw "Only one $($this.GetType().Name) object can be created"
}
Add-SingletonObject $this.GetType().Name $this
([ViewObjectBase]$this).Init()
}
# Hidden Functions
Hidden Init()
{
$this._SessionId = $script:SessionID
Add-ObjectProperty $this "ID" { $this._ID }
Add-ObjectProperty $this "Title" { $this._Title }
Add-ObjectProperty $this "Description" { $this._Description }
Add-ObjectProperty $this "Authentication" { $this._AuthenticaionObject }
Add-ObjectProperty $this "HideMenu" { $this._HideMenu }
Add-ObjectProperty $this "ViewPanel" { $this.GetViewPanel() }
Add-ObjectProperty $this "AddToMenu" { $this._AddToMenu }
Add-ObjectProperty $this "ExpandInViewsMenu" { $this._ExpandInViewsMenu }
}
[Object[]]GetViewItems()
{
return $null
}
[Object]GetViewPanel()
{
return $null
}
Authenticate()
{
}
[Object[]]OnItemChanged($SelectedItem)
{
return $null
}
OnDeactivating($NewActiveView)
{
}
OnActivating($PreviousActiveView)
{
}
OnActivated()
{
}
}
class ViewItemBase
{
[String]$Id = ""
[String]$Name = ""
[String]$Icon = $null
ViewItemBase()
{
}
}
+104
View File
@@ -0,0 +1,104 @@
#ImportOrder 10
function Add-NativeClass
{
param(
[string]
$ClassName,
[string]
$ClassDefinition,
[String[]]
$AssembliesPartialName
)
# `-as [type]` yields a Type object or $null - never $true. Comparing a Type to
# $true coerces the right side to Type, which never matches, so this guard used
# to be dead: Add-Type ran on every re-import, the CLR rejected the
# already-loaded type, and the catch below logged "Failed to add type" every
# time. Harmless but it made a clean re-import look broken.
if ($ClassName -as [type]) { return }
[Reflection.Assembly]::LoadWithPartialName("System.ComponentModel") | Out-Null
foreach($Assembly in $AssembliesPartialName) {
[Reflection.Assembly]::LoadWithPartialName($Assembly) | Out-Null
}
try {
Write-Log "Add class $ClassName"
Add-Type -TypeDefinition $ClassDefinition -IgnoreWarnings -ErrorAction Stop #-ReferencedAssemblies @('System.ComponentModel')
}
catch {
Write-LogError "Failed to add type $($ClassName)" $_.Exception
Write-LogDebug "Definition:`n$ClassDefinition"
}
}
$classDef = @"
using System;
using System.ComponentModel;
public class ObjectColumnInfo : System.ComponentModel.INotifyPropertyChanged
{
public string Property { get { return _property; } set { _property = value; NotifyPropertyChanged("Property"); } }
private string _property = null;
public string Header { get { return _header; } set { _header = value; NotifyPropertyChanged("Header"); } }
private string _header = null;
public ObjectColumnInfo(string Property, string Header)
{
_property = Property;
_header = Header;
}
public override string ToString()
{
if(!String.IsNullOrEmpty(_header)) { return _header; }
return _property ?? String.Empty;
}
public event PropertyChangedEventHandler PropertyChanged;
// This method is called by the Set accessor of each property.
// The CallerMemberName attribute that is applied to the optional propertyName
// parameter causes the property name of the caller to be substituted as an argument.
private void NotifyPropertyChanged(string propertyName = "")
{
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
}
}
"@
Add-NativeClass -ClassName "ObjectColumnInfo" -ClassDefinition $classDef
$classDef = @"
using System;
using System.ComponentModel;
public class NameValueObject : System.ComponentModel.INotifyPropertyChanged
{
public string Name { get { return _name; } set { _name = value; NotifyPropertyChanged("Name"); } }
private string _name = null;
public string Value { get { return _value; } set { _value = value; NotifyPropertyChanged("Value"); } }
private string _value = null;
public NameValueObject(string Name, string Value)
{
_name = Name;
_value = Value;
}
public event PropertyChangedEventHandler PropertyChanged;
// This method is called by the Set accessor of each property.
// The CallerMemberName attribute that is applied to the optional propertyName
// parameter causes the property name of the caller to be substituted as an argument.
private void NotifyPropertyChanged(string propertyName = "")
{
if(PropertyChanged != null) { PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
}
}
"@
Add-NativeClass -ClassName "NameValueObject" -ClassDefinition $classDef -AssembliesPartialName "System.ComponentModel"
+220
View File
@@ -0,0 +1,220 @@
#ImportOrder 220
#########################################################################################
#
# Entra Enrollment Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class EntraGroup : IntunePolicyGroupBase
{
EntraGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "Entra"
$this._Name = "Entra"
$this._Icon = "Entra"
}
}
#########################################################################################
#
# Entra Branding
#
#########################################################################################
# region Entra Branding
class EntraBrandingType : IntunePolicyTypeBase
{
EntraBrandingType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
$this._PolicyName = "Entra Branding"
$this._ID = "AzureBranding"
$this._HasPlatform = $false
$this._HasModified = $false
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "organization/%OrganizationId%/branding/localizations"
$this._Permissions = @("Organization.ReadWrite.All")
$this._NameProperty = "Id"
$this._Icon = "Branding"
#$this._ShowButtons = @("Export","View")
$this._ExpandAssignments = $false
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._SkipAddIDOnFileName = $true
$this._TopItems = 0
# Graph rejects `?$top=...` on /organization/<tid>/branding/localizations
# with HTTP 400. Mirrors `SupportsPageSize=$false` in the OLD project's
# AzureBranding type definition; without it, bulk export's default of
# `$top=1000` makes the listing call fail and the type silently produces
# zero files.
$this._HasPageSizeSupport = $false
$this._ObjectClass = "EntraBrandingObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
$ret = @{}
# ToDo: Verify functionallity for import
Remove-Property $PolicyObject.JsonObject "@odata.Type"
if($PolicyObject.JsonObject.Id -eq "0")
{
$ret.Add("Method","PATCH") # Default profile always exists so update it
$ret.Add("API", "organization/%OrganizationId%/branding")
}
# This is NOT what the documentation says
# Documentation says to use Content-Language
# Only place the documentation states to use Accept-Language is for Get operation
# https://docs.microsoft.com/en-us/graph/api/organizationalbrandingproperties-get?view=graph-rest-beta&tabs=http#request-headers
$ret.Add("AdditionalHeaders", @{ "Accept-Language" = $PolicyObject.JsonObject.Id })
return $ret
}
}
Class EntraBrandingObject : IntunePolicyBase
{
Hidden [String]$_Language = $null
EntraBrandingObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
EntraBrandingObject() : Base()
{
$this.Init()
}
Hidden Init()
{
if($this.Object.id -eq "0")
{
$this._Language = "Default"
}
elseif($this.Object.id)
{
$this._Language = ([cultureinfo]::GetCultureInfo($this.Object.id)).DisplayName
}
Add-ObjectProperty $this "Language" { $this._Language }
$this._PolicyType = (Get-SingletonObject "EntraBrandingType")
}
}
#endregion
#########################################################################################
#
# Terms and Condition
#
#########################################################################################
# region Terms and Condition
class TermsAndConditionType : IntunePolicyTypeBase
{
TermsAndConditionType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "EntraGroup")
$this._APITitle = "Terms and Conditions"
$this._PolicyName = "Terms and Condition"
$this._ID = "TermsAndConditions"
$this._HasPlatform = $false
$this._API = "deviceManagement/termsAndConditions"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
$this._ExpandAssignments = $false
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "TermsAndConditionObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
return (Add-GraphAssignmentsToObject $PolicyObject $SourceObject)
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
# Hydration already fanned out /assignments via the
# _HasSubResourceBatch contract on TermsAndConditionObject — skip
# the per-policy round-trip the helper would otherwise make.
if($script:_skipDirectGet -eq $true) { return }
Add-GraphAssignmentsToExportFile $PolicyObject $PathToFile
}
}
Class TermsAndConditionObject : IntunePolicyBase
{
TermsAndConditionObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
TermsAndConditionObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "TermsAndConditionType")
# Opt into the bulk-export sub-resource batching contract so the
# /assignments side-channel gets fanned out via $batch instead of
# one synchronous round-trip per policy in PostExportCommand.
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
return @([PSCustomObject]@{
Key = 'assignments'
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
})
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -eq 'assignments') {
# Comma-prefix forces an array reference through the if-expression —
# without it, PowerShell unwraps a single-element @() on assignment
# and ConvertTo-Json then emits a bare object instead of [{...}].
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
}
return @()
}
}
# AppleEnrollmentTypeObject lives in IntuneAppleClasses.ps1 — was duplicated here,
# which both confused PowerShell's parser ("The member 'AppleEnrollmentTypeObject'
# is already defined" when the class files are concatenated for static analysis)
# and risked a non-deterministic resolution depending on which file's definition
# the runtime committed last. Single source of truth in IntuneAppleClasses.ps1
# (loaded via the same ImportOrder = 220) is sufficient.
#endregion
+40
View File
@@ -0,0 +1,40 @@
#ImportOrder 24
# Canonical, provider-agnostic authentication token descriptor.
#
# One typed shape used everywhere a token/identity is surfaced: the auth-event
# payloads (AuthenticatedNewToken / AuthenticationTokenRefresh /
# AuthenticationUserDisconnected), every provider's GetUserInfo() return value,
# the central token registry in Internal/AuthenticationCore.ps1, and the public
# Get-IMAuthToken function.
#
# Providers populate everything they can see from the token itself (Provider,
# TenantId, TenantName, Cloud, Account/UPN/UserId, AppId/AppName, AuthType,
# ExpiresOn). The registry overlays the two fields a provider cannot know on its
# own: the GLOBAL TokenId (registry-allocated, unique across providers) and
# IsDefault (derived from the registry's single default-id).
#
# ImportOrder 24: must parse before AuthenticationProvider.ps1 (#ImportOrder 25),
# whose GetUserInfo signature returns [IMAuthToken], and the concrete providers
# (26/27). Core primitives load at 1/10, so 24 is free and safely after them.
class IMAuthToken {
[int] $TokenId # GLOBAL id (registry-allocated); 0 = unassigned
[string] $Provider # owning provider Id: "MSAL" | "OAuth" | "MgGraph"
[string] $TenantId
[string] $TenantName
[string] $Cloud # "Public" | "USGov" | "USGovDOD" | "China"
[string] $Account # display name (UPN, or app name for app-only)
[string] $UPN
[string] $UserId
[string] $AppId
[string] $AppName
[string] $AuthType # Interactive | ClientCredential | BYO | ManagedIdentity | WorkloadFederation | Password
[bool] $IsDefault
[Nullable[datetime]] $ExpiresOn
[string] ToString() {
$tenant = if ($this.TenantName) { $this.TenantName } elseif ($this.TenantId) { $this.TenantId } else { '?' }
return "$($this.Provider)/$tenant ($($this.TokenId))"
}
}
+455
View File
@@ -0,0 +1,455 @@
#ImportOrder 220
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AppConfigurationGroup : IntunePolicyGroupBase
{
AppConfigurationGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "AppConfiguration"
$this._Name = "App configuration policies"
$this._Icon = "AppConfiguration"
$this._ExtraColumns = @("EnrolmentType=Enrolment type")
}
}
#########################################################################################
#
# App Configuration (App)
#
#########################################################################################
# region App Configuration (App)
class AppConfigurationManagedAppType : IntunePolicyTypeBase
{
AppConfigurationManagedAppType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
$this._PolicyName = "App configuration (App)"
$this._ID = "AppConfigurationManagedApp"
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceAppManagement/targetedManagedAppConfigurations"
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
$this._Dependencies = @("Applications")
$this._ObjectClass = "AppConfigurationManagedAppObject"
$this._ExpandAssignmentsList = $false
# CheckPolicy authoritatively matches this type by @odata.type (plus the base
# @odata.id fallback), so a rejection is real - skip the folder-trust fallback.
$this._StrictODataTypeCheck = $true
$this._Icon = "AppConfiguration"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
# targetedManagedAppConfiguration is surfaced by the polymorphic
# managedAppPolicies collection alongside App Protection variants, so match it
# explicitly by @odata.type. This also lets a file object (which carries only
# @odata.type, no top-level @odata.id) resolve to this type; the base
# @odata.id-based CheckPolicy would reject it.
if($PolicyObject.'@odata.type' -eq '#microsoft.graph.targetedManagedAppConfiguration')
{
return $true
}
# Fall back to the base @odata.id matcher (deviceAppManagement/targetedManagedAppConfigurations)
# for objects that carry an id but no top-level @odata.type.
return ([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject)
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
# apps is a navigation property set after create via targetApps
# (PostImportCommand); strip it, then POST to the type API
# (deviceAppManagement/targetedManagedAppConfigurations).
Remove-Property $PolicyObject.JsonObject "apps"
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
return $null
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($SourceObject.Object.Apps) {
# No "@odata.type" on the created object so reload new object
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
if($newObject)
{
try
{
$apps = [PSCustomObject]@{
appGroupType = $PolicyObject.Object.appGroupType
apps = @($SourceObject.Object.Apps)
}
$json = $apps | ConvertTo-Json -Depth 20
Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
}
catch {
# The policy was created; only the targetApps association failed. Keep
# going but make the partial import visible instead of swallowing it.
Write-LogError "Failed to assign target apps to imported App configuration policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via $($this.API)/$($PolicyObject.Id)/targetApps" $_.Exception
}
}
}
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
{
# apps is a navigation property set via targetApps, not inline. Re-post
# apps to the type API, strip them from the PATCH body, then PATCH the
# type API (deviceAppManagement/targetedManagedAppConfigurations).
if($PolicyObject.JsonObject.apps)
{
try
{
$apps = [PSCustomObject]@{
appGroupType = $PolicyObject.JsonObject.appGroupType
apps = @($PolicyObject.JsonObject.apps)
}
$json = $apps | ConvertTo-Json -Depth 20
Invoke-MSGraphAPI -Url "$($this.API)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
}
catch {
# The policy PATCH still proceeds; surface the failed targetApps update.
Write-LogError "Failed to update target apps for App configuration policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via $($this.API)/$($ExistingObject.Id)/targetApps" $_.Exception
}
}
Remove-Property $PolicyObject.JsonObject "apps"
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
# assignments is a navigation property (managed via the /assign action),
# not inline-PATCHable.
Remove-Property $PolicyObject.JsonObject "assignments"
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
return $null
}
}
Class AppConfigurationManagedAppObject : IntunePolicyBase
{
Hidden [string]$_objectClass = $null
AppConfigurationManagedAppObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppConfigurationManagedAppObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedAppType")
Add-ObjectProperty $this "EnrolmentType" { "Managed apps" }
Get-AppConfigurationClass $this
# Targeted-app resolution runs through the sub-resource contract; see the
# Get-/Add-/Build-AppConfigTargetApp* helpers below.
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
return (Get-AppConfigTargetAppRequests $this)
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
return @()
}
[void] FinalizeSubResources()
{
Build-AppConfigTargetAppRefs $this
}
}
#########################################################################################
#
# App Configuration (Device)
#
#########################################################################################
# region App Configuration (Device)
class AppConfigurationManagedDeviceType : IntunePolicyTypeBase
{
AppConfigurationManagedDeviceType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "AppConfigurationGroup")
$this._PolicyName = "App configuration (Device)"
$this._ID = "AppConfigurationManagedDevice"
$this._API = "deviceAppManagement/mobileAppConfigurations"
$this._QueryList = "?`$filter=microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig%20eq%20false%20or%20isof(%27microsoft.graph.androidManagedStoreAppConfiguration%27)%20eq%20false"
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
$this._Dependencies = @("Applications")
$this._ObjectClass = "AppConfigurationManagedDeviceObject"
$this._ExpandAssignmentsList = $false
$this._Icon = "AppConfiguration"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
return (@{"API"="deviceAppManagement/mobileAppConfigurations/$($PolicyObject.Id)/microsoft.graph.managedDeviceMobileAppConfiguration/assign"})
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
Import-AppConfigurationTargetedApps $PolicyObject
return $null
}
}
Class AppConfigurationManagedDeviceObject : IntunePolicyBase
{
AppConfigurationManagedDeviceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppConfigurationManagedDeviceObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppConfigurationManagedDeviceType")
Add-ObjectProperty $this "EnrolmentType" { "Managed devices" }
# Targeted-app resolution runs through the sub-resource contract; see the
# Get-/Add-/Build-AppConfigTargetApp* helpers.
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
return (Get-AppConfigTargetAppRequests $this)
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1) { Add-AppConfigTargetAppResult $Key $Body }
return @()
}
[void] FinalizeSubResources()
{
Build-AppConfigTargetAppRefs $this
}
}
#########################################################################################
#
# Generic Functions
#
#########################################################################################
function Get-AppConfigurationClass
{
param($Policy)
try {
$tmp = $Policy.Object."@odata.type".Split('.')[-1]
$Policy._objectClass = Get-GraphObjectClassName $tmp
}
catch { }
if($null -eq $Policy._objectClass) {
Write-Log "Could not get class name for $($Policy.Name) ($($Policy.Object."@odata.type"))" 3
}
}
function Get-AppConfigurationFullObject
{
param($Policy)
if(-not $Policy.Object."@odata.type" -or -not $Policy._objectClass) { return $false }
$expand = $null
if($Policy._objectClass -eq "windowsInformationProtectionPolicies")
{
$expand = "?`$expand=protectedAppLockerFiles,exemptAppLockerFiles"
}
else {
$url = $Policy.GetObjectURL()
$tmpArr = $url.Split("?")
if($tmpArr.Length -gt 1) {
$expand = "?" + $tmpArr[1]
}
}
$fullObject = (Invoke-MSGraphAPI -Url "deviceAppManagement/$($Policy._objectClass)/$($Policy.Id)$expand" -TokenId $Policy._TokenId)
if($fullObject)
{
$Policy.JsonObject = $fullObject
$Policy._IsFullObject = $true
return $true
}
return $false
}
# Targeted-app resolution for AppConfiguration policies.
#
# The policy body lists app IDs in targetedMobileApps; cross-tenant export needs
# each app's displayName + @odata.type to re-map them on import into another
# tenant (#CustomRefTargetedApps). The mobileApps/<id> lookup is owned ONLY by
# Get-AppConfigTargetAppRequests below — the sub-resource contract on the
# AppConfiguration*Object classes drives the fetch (coalesced across policies by
# Invoke-PolicySubresourceFetch's URL de-dup), caches results, then builds the
# ref string. Previously this was duplicated in Sync-BulkExportAppConfigurationTargetApps
# (Internal/PolicyHydrateExtras.ps1).
# Process-wide cache: appId -> app body (or $null for a 404/miss). Shared across
# every AppConfig policy in a hydrate run so the same app is fetched once.
function Get-AppConfigTargetAppCache
{
if($null -eq $script:_appConfigTargetAppCache) { $script:_appConfigTargetAppCache = @{} }
return $script:_appConfigTargetAppCache
}
# Only these polymorphic AppConfig @odata.types carry targetedMobileApps that
# need tenant-specific remapping. (androidManagedAppProtection is listed for
# parity with the legacy filter; App Protection policies use `apps`, not
# `targetedMobileApps`, so they never actually match.)
function Test-AppConfigHasTargetApps
{
param($Policy)
return ($Policy.JsonObject.'@OData.Type' -in @(
'#microsoft.graph.androidManagedAppProtection',
'#microsoft.graph.androidForWorkMobileAppConfiguration',
'#microsoft.graph.androidManagedStoreAppConfiguration',
'#microsoft.graph.iosMobileAppConfiguration'
) -and @($Policy.JsonObject.targetedMobileApps).Count -gt 0)
}
# Sub-resource requests for every targeted app not already cached. The
# deviceAppManagement/mobileApps/<id> URL lives ONLY here.
function Get-AppConfigTargetAppRequests
{
param($Policy)
if(-not (Test-AppConfigHasTargetApps $Policy)) { return @() }
$cache = Get-AppConfigTargetAppCache
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
if(-not $appId -or $cache.ContainsKey($appId)) { continue }
[void]$reqs.Add([PSCustomObject]@{
Key = "targetApp_$appId"
Url = "deviceAppManagement/mobileApps/$appId"
Headers = @{ Accept = 'application/json;odata.metadata=minimal' }
})
}
return $reqs.ToArray()
}
# Cache one targeted-app sub-resource response. Stores $null for misses so 404s
# aren't re-requested by a later policy in the same run.
function Add-AppConfigTargetAppResult
{
param([string]$Key, $Body)
if($Key -notlike 'targetApp_*') { return }
$appId = $Key.Substring('targetApp_'.Length)
(Get-AppConfigTargetAppCache)[$appId] = $Body
}
# Build #CustomRefTargetedApps from the cached app bodies (finalize step).
function Build-AppConfigTargetAppRefs
{
param($Policy)
if(-not (Test-AppConfigHasTargetApps $Policy)) { return }
$cache = Get-AppConfigTargetAppCache
$targetedApps = @()
foreach($appId in @($Policy.JsonObject.targetedMobileApps)) {
$appObj = $cache[$appId]
if($appObj) {
Write-Log "Add target app info $($appObj.displayName) ($($appObj.Id)) of type $($appObj.'@OData.Type')"
$targetedApps += $appObj.displayName + '|!|' + $appObj.Id + '|!|' + $appObj.'@OData.Type'
}
else {
Write-Log "No app found with id $appId" 2
}
}
if($targetedApps.Count -gt 0) {
Add-Member -InputObject $Policy.JsonObject -MemberType NoteProperty -Name '#CustomRefTargetedApps' -Value ($targetedApps -join '|*|') -Force
}
}
function Import-AppConfigurationTargetedApps
{
param($Policy)
if($Policy.JsonObject."#CustomRefTargetedApps" -and $Policy.JsonObject.targetedMobileApps)
{
Write-Log "Adding app targets for $($Policy.JsonObject.displayName)"
$targetedAppsInfo = $Policy.JsonObject."#CustomRefTargetedApps"
$translatedTargetedApps = @()
if($targetedAppsInfo)
{
foreach($targetedApp in ($targetedAppsInfo -split "[|][*][|]"))
{
$appName, $appId, $appType = $targetedApp -split "[|][!][|]"
if(-not $appName -or -not $appId)
{
Write-Log "App Name and Id is missing in string: $appApp" 2
continue
}
$tmpApps = (Invoke-MSGraphAPI -Url "/deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $Policy._TokenId).value
if(-not $tmpApps)
{
Write-Log "No application found with name $appName. $appId will not be translated and added to target list" 2
continue
}
$tmpApp = $tmpApps | Where-Object '@OData.Type' -eq $appType
if(-not $tmpApp)
{
Write-Log "No $appName application found of type $appType. $appId will not be translated and added to target list" 2
}
elseif(($tmpApp | Measure-Object).Count -gt 1) {
Write-Log "$(($tmpApp | Measure-Object).Count) applications found with name '$appName' of type $appType. $appId will not be translated and added to target list" 2
}
else {
Write-Log "Found '$appName' with id $($tmpApp.Id) ($appType)"
$translatedTargetedApps += $tmpApp.Id
}
}
if($translatedTargetedApps.Count -gt 0) {
Write-Log "Updating translated targeted apps"
$Policy.JsonObject.targetedMobileApps = $translatedTargetedApps
}
else {
Write-Log "Could not find targeted apps in the evnironment. Verify that they are added. Policy import might fail" 3
}
}
}
}
+245
View File
@@ -0,0 +1,245 @@
#ImportOrder 220
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AppProtectionGroup : IntunePolicyGroupBase
{
AppProtectionGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "AppProtection"
$this._Name = "App protection policies"
$this._Icon = "AppProtection"
}
}
#########################################################################################
#
# App Protection
#
#########################################################################################
# region App Protection
class AppProtectionType : IntunePolicyTypeBase
{
AppProtectionType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "AppProtectionGroup")
$this._PolicyName = "App protection policy"
$this._ID = "AppProtection"
$this._SubTypeColumn = "ManagementType=Management type"
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceAppManagement/managedAppPolicies"
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
$this._Dependencies = @("Applications")
$this._ObjectClass = "AppProtectionPolicyObject"
$this._ExpandAssignmentsList = $false
# Assignments are fetched per platform collection - see
# GetAssignmentsBaseURL below. _AssignmentsViaExpand stays $false:
# once the URL targets a concrete subtype the plain navigation GET
# works, and it returns just the assignments instead of the whole policy.
$this._PropertiesToRemove = @('exemptAppLockerFiles')
$this._PropertiesToRemoveForUpdate = @("protectedAppLockerFiles","version") # ToDo: !!! Add support for protectedAppLockerFiles?
$this._VerifyObject = $true
# CheckPolicy is a complete @odata.type matcher (the managedAppPolicies allowlist),
# so a rejection is authoritative - do not let the folder-trust fallback rescue a
# foreign object misplaced in this type's export folder.
$this._StrictODataTypeCheck = $true
$this._Icon = "AppConfiguration"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
# managedAppPolicies is Collection(managedAppPolicy), and managedAppPolicy
# declares no navigation properties - so both {API}/{id}/assignments and
# {API}/{id}?$expand=assignments return 400 ("Could not find a property named
# 'assignments' on type 'microsoft.graph.managedAppPolicy'"). Every concrete
# subtype inherits `assignments`, so route through the per-platform collection
# (_objectClass, set in the object's constructor via Get-AppConfigurationClass).
# defaultManagedAppProtection is the exception - it has no assignments at all.
[String]GetAssignmentsBaseURL([PSCustomObject]$PolicyObject)
{
if(-not $PolicyObject._objectClass) { return $this._API }
if($PolicyObject._objectClass -eq "defaultManagedAppProtections") { return $null }
return "deviceAppManagement/$($PolicyObject._objectClass)"
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
# apps is a navigation property set later via targetApps (PostImportCommand),
# not an inline body property - strip it from the POST body.
Remove-Property $PolicyObject.JsonObject "apps"
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
# The polymorphic managedAppPolicies collection (used for listing) rejects
# POST, so import must target the per-platform collection. _objectClass is
# the metadata-derived endpoint segment (e.g. iosManagedAppProtections),
# set on the object at construction via Get-AppConfigurationClass.
if($PolicyObject._objectClass)
{
return @{"API"="deviceAppManagement/$($PolicyObject._objectClass)"}
}
return (@{})
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($SourceObject.Object.Apps) {
# No "@odata.type" on the created object so reload new object
#$newObject = (Invoke-MSGraphAPI "$($objectType.API)?`$filter=id eq '$($obj.Id)'").Value
$newObject = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -TokenId $PolicyObject._TokenID
if($newObject)
{
try
{
$apps = [PSCustomObject]@{
appGroupType = $PolicyObject.Object.appGroupType
apps = @($SourceObject.Object.Apps)
}
$json = $apps | ConvertTo-Json -Depth 20
# Created object carries no @odata.type; use the source object's
# metadata-derived endpoint segment (_objectClass).
if($SourceObject._objectClass)
{
Invoke-MSGraphAPI -Url "deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $PolicyObject._TokenID | Out-Null
}
}
catch {
# The policy was created; only the targetApps association failed. Keep
# going but make the partial import visible instead of swallowing it.
Write-LogError "Failed to assign target apps to imported App protection policy '$($PolicyObject.displayName)' ($($PolicyObject.Id)) via deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/targetApps" $_.Exception
}
}
}
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
# /assign is bound to the concrete platform subtype; the polymorphic
# managedAppPolicies collection returns 400 for the assign action.
if($SourceObject._objectClass)
{
return @{"API"="deviceAppManagement/$($SourceObject._objectClass)/$($PolicyObject.Id)/assign"}
}
return $null
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$ExistingObject)
{
# managedAppPolicies rejects PATCH, and apps is a navigation property set
# via targetApps rather than inline. Re-post apps to the per-platform
# endpoint, strip them from the PATCH body, then PATCH that endpoint.
#
# Routing note: an imported object's POST response carries no
# @odata.type, so ITS _objectClass can be null - the update object came
# from a file that always has the type, so prefer that one.
if(-not $ExistingObject._objectClass -and $PolicyObject._objectClass)
{
$ExistingObject._objectClass = $PolicyObject._objectClass
}
if($PolicyObject.JsonObject.apps -and $ExistingObject._objectClass)
{
try
{
$apps = [PSCustomObject]@{
appGroupType = $PolicyObject.JsonObject.appGroupType
apps = @($PolicyObject.JsonObject.apps)
}
$json = $apps | ConvertTo-Json -Depth 20
Invoke-MSGraphAPI -Url "deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" -Content $json -HttpMethod POST -TokenId $ExistingObject._TokenID | Out-Null
}
catch {
# The policy PATCH still proceeds; surface the failed targetApps update.
Write-LogError "Failed to update target apps for App protection policy '$($ExistingObject.displayName)' ($($ExistingObject.Id)) via deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)/targetApps" $_.Exception
}
}
Remove-Property $PolicyObject.JsonObject "apps"
Remove-Property $PolicyObject.JsonObject "apps@odata.context"
# assignments is a navigation property (managed via the /assign action),
# not inline-PATCHable - PATCHing it 400s on the platform entity type.
Remove-Property $PolicyObject.JsonObject "assignments"
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
if($ExistingObject._objectClass)
{
return @{"API"="deviceAppManagement/$($ExistingObject._objectClass)/$($ExistingObject.Id)"}
}
return $null
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
# App Protection owns the polymorphic deviceAppManagement/managedAppPolicies
# collection: the per-platform *ManagedAppProtection variants (ios / android /
# windows / default) plus the two Windows Information Protection policy types.
# An explicit allowlist is required: file objects carry only @odata.type (no
# top-level @odata.id), so this runs as the file->type discriminator. A previous
# "accept everything except targetedManagedAppConfiguration" greedily claimed
# unrelated policy types (Compliance, CA, etc.) when resolving from an export
# folder. targetedManagedAppConfiguration is App Config, not App Protection
# (see AppConfigurationManagedAppType.CheckPolicy).
$odata = [string]$PolicyObject.'@odata.type'
if($odata -match 'ManagedAppProtection$' -or
$odata -eq '#microsoft.graph.mdmWindowsInformationProtectionPolicy' -or
$odata -eq '#microsoft.graph.windowsInformationProtectionPolicy')
{
return $true
}
return $false
}
}
Class AppProtectionPolicyObject : IntunePolicyBase
{
Hidden [string]$_objectClass = $null
Hidden [string]$_managemntType = $null
AppProtectionPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppProtectionPolicyObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppProtectionType")
if($this.Object."@odata.type" -eq "#microsoft.graph.mdmWindowsInformationProtectionPolicy") {
$this._managemntType = "With enrollment"
}
elseif($this.Object."@odata.type" -eq "#microsoft.graph.windowsInformationProtectionPolicy") {
$this._managemntType = "Without enrollment"
}
else {
$this._managemntType = "All app types"
}
Add-ObjectProperty $this "ManagementType" { $this._managemntType }
Get-AppConfigurationClass $this
if($this.JsonObject."@odata.type" -eq "#microsoft.graph.iosManagedAppProtection") {
$platformName = Get-LanguageString "AppProtection.iOSPlatformLabel"
if($platformName) {
#$this._PlatformName = $platformName
}
}
}
}
+77
View File
@@ -0,0 +1,77 @@
#ImportOrder 220
#########################################################################################
#
# Apple Enrollment Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AppleEnrollmentGroup : IntunePolicyGroupBase
{
AppleEnrollmentGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "AppleEnrollment"
$this._Name = "Apple Enrollment"
$this._Icon = "AppleEnrollmentTypes"
}
}
#########################################################################################
#
# Apple Enrollment Types
#
#########################################################################################
# region Apple Enrollment Types
class AppleEnrollmentType : IntunePolicyTypeBase
{
AppleEnrollmentType() : Base()
{
$this.Init()
}
Init()
{
# _PolicyGroup must be AppleEnrollmentGroup (the group declared at the top of
# this file). The original wiring pointed at AppleUpdateGroup, which is the
# software-update group — wrong taxonomy.
# _ObjectClass was never set, so IntunePolicyTypeBase.GetObject took the
# "Object class is missing" branch and dropped every returned row, surfacing
# as 'no Apple Enrollment Types objects matched' even though the API returned
# data. Wire it up to AppleEnrollmentTypeObject (defined in this same file).
$this._PolicyGroup = (Get-SingletonObject "AppleEnrollmentGroup")
$this._APITitle = "Apple Enrollment Types"
$this._PolicyName = "Apple Enrollment Type"
$this._ID = "AppleEnrollmentTypes"
$this._API = "deviceManagement/appleUserInitiatedEnrollmentProfiles"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
$this._ObjectClass = "AppleEnrollmentTypeObject"
$this._PropertiesToRemoveForUpdate = @('platform')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class AppleEnrollmentTypeObject : IntunePolicyBase
{
AppleEnrollmentTypeObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppleEnrollmentTypeObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppleEnrollmentType")
}
}
#endregion
+137
View File
@@ -0,0 +1,137 @@
#ImportOrder 220
#########################################################################################
#
# Apple Update Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AppleUpdateGroup : IntunePolicyGroupBase
{
AppleUpdateGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "AppleUpdates"
$this._Name = "Apple updates"
$this._Icon = "AppleUpdates"
}
}
#########################################################################################
#
# iOS/iPadOS Updates
#
#########################################################################################
# region iOS/iPadOS Updates
class iOSiPadOSPolicyType : IntunePolicyTypeBase
{
iOSiPadOSPolicyType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
$this._PolicyName = "iOS/iPadOS update policies"
$this._ID = "iOSiPadOSUpdatePolicies"
$this._API = "deviceManagement/deviceConfigurations"
$this._QueryList = "?`$filter=isof(%27microsoft.graph.iosUpdateConfiguration%27)"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "iOSiPadOSPolicyObject"
$this._Icon = "iOSUpdates"
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 90
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.iosUpdateConfiguration") { return $false }
return $true
}
}
Class iOSiPadOSPolicyObject : IntunePolicyBase
{
iOSiPadOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
iOSiPadOSPolicyObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "iOSiPadOSPolicyType")
}
}
#########################################################################################
#
# MacOS Updates
#
#########################################################################################
# region MacOS Updates
class macOSPolicyType : IntunePolicyTypeBase
{
macOSPolicyType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "AppleUpdateGroup")
$this._PolicyName = "macOS update policies"
$this._ID = "macOSUpdatePolicies"
$this._API = "deviceManagement/deviceConfigurations"
$this._QueryList = "?`$filter=isof(%27microsoft.graph.macOSSoftwareUpdateConfiguration%27)"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "macOSPolicyObject"
$this._Icon = "MacOSUpdates"
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 90
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.macOSSoftwareUpdateConfiguration") { return $false }
return $true
}
}
Class macOSPolicyObject : IntunePolicyBase
{
macOSPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
macOSPolicyObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "macOSPolicyType")
}
}
+767
View File
@@ -0,0 +1,767 @@
#ImportOrder 220
#########################################################################################
#
# Applications Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class ApplicationsGroup : IntunePolicyGroupBase
{
ApplicationsGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "Applications"
$this._Name = "Applications"
$this._Icon = "Applications"
# Type (Win32, iOS store, ...) is the discriminator here; Policy type would read
# "Application" on every row but the iOS provisioning profiles.
$this._ExtraColumns = @("ApplicationType=Type")
$this._ShowPolicyTypeColumn = $false
}
}
#########################################################################################
#
# Application Type
#
#########################################################################################
# region Application Type
class ApplicationType : IntunePolicyTypeBase
{
ApplicationType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
$this._APITitle = "Applications"
$this._PolicyName = "Applications"
$this._ID = "Applications"
$this._API = "deviceAppManagement/mobileApps"
$this._QueryList = "?`$filter=(microsoft.graph.managedApp/appAvailability eq null or microsoft.graph.managedApp/appAvailability eq 'lineOfBusiness' or isAssigned eq true)&`$orderby=displayName"
$this._QuerySearch = $true
$this._Expand = "categories,assignments" # ODataMetadata is set to minimal so assignments can't be autodetected
$this._ODataMetadata = "minimal" # categories property not supported with ODataMetadata full
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
$this._PropertiesToRemove = @('uploadState','publishingState','isAssigned','dependentAppCount','supersedingAppCount','supersededAppCount','committedContentVersion','isFeatured','size','categories') #,'minimumSupportedWindowsRelease'
$this._AssignmentsType = "mobileAppAssignments"
$this._AssignmentPropertiesToKeep = @("@odata.type","target","settings","intent")
$this._AssignmentTargetPropertiesToKeep = @("@odata.type","groupId","deviceAndAppManagementAssignmentFilterId","deviceAndAppManagementAssignmentFilterType")
$this._ScopeTagsReturnedInList = $false
$this._ExpandAssignmentsList = $false
$this._ImportOrder = 60
$this._ObjectClass = "ApplicationObject"
$this._SubTypeColumn = "ApplicationType=Type"
$this._ExtraColumns = @("ApplicationTypeGroup=App type")
# appUrl: Graph rejects a PATCH that carries it ("The property 'AppUrl'
# cannot be patched") - a web app's URL is fixed at creation, as in the
# portal. Only webApp has the property, so stripping it is safe for all.
$this._PropertiesToRemoveForUpdate = @('platform', 'appUrl')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
if($PolicyObject.JsonObject.'@OData.Type' -in @('#microsoft.graph.microsoftStoreForBusinessApp','#microsoft.graph.androidStoreApp'))
{
Write-Log "App type '$($PolicyObject.JsonObject.'@OData.Type')' not supported for import" 2
return @{ "Import" = $false }
}
if($PolicyObject.JsonObject.'@OData.Type' -eq '#microsoft.graph.officeSuiteApp')
{
if($PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat -eq "notConfigured")
{
$PolicyObject.JsonObject.officeSuiteAppDefaultFileFormat = "officeOpenXMLFormat"
}
}
return $null
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
$tmpFilName = $null
if($SourceObject.IsFromFile) {
if(-not ($PolicyObject.JsonObject.PSObject.Properties | Where-Object Name -eq '@odata.type'))
{
# Add @odata.type property if it is missing. Required by app package import
$PolicyObject.JsonObject | Add-Member -MemberType NoteProperty -Name '@odata.type' -Value $SourceObject.JsonObject.'@odata.type'
}
$fi = $SourceObject.FileInfo
$tmpFilName = [IO.Path]::Combine($fi.DirectoryName, [string]$SourceObject.JsonObject.FileName)
if([IO.File]::Exists($tmpFilName) -eq $false)
{
Write-LogDebug "App content file not found in Json folder: '$tmpFilName'"
$tmpFilName = $null
}
}
else {
}
Start-ApplicationImportFile $PolicyObject $tmpFilName
Start-ApplicationAddInstallScripts $PolicyObject $SourceObject
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
$fi = [IO.FileInfo]"$PathToFile"
if((Get-CacheObject "ExportScripts") -eq $true) {
try
{
foreach($rule in ($PolicyObject.JsonObject.detectionRules | Where-Object '@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptDetection"))
{
if($rule.ScriptContent)
{
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_DetectionScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
}
}
foreach($rule in $PolicyObject.JsonObject.requirementRules)
{
if($rule.'@OData.Type' -eq "#microsoft.graph.win32LobAppPowerShellScriptRequirement")
{
if($rule.ScriptContent)
{
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_RequirementScript.ps1")), ([System.Convert]::FromBase64String($rule.ScriptContent)))
}
}
}
if($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)
{
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeInstallScript.'#ScriptInfo'.content)))
}
if($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)
{
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_$($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.displayName)")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.activeUninstallScript.'#ScriptInfo'.content)))
}
}
catch
{
Write-LogError "Failed to export application scripts" $_.Exception
}
}
Save-SettingStoreValue "Intune" "ExportAppFile" (Get-CacheObject "ExportAppContent")
if((Get-CacheObject "ExportAppContent") -eq $true) {
if($script:_skipDirectGet -eq $true) {
Write-Log "Bulk export: app content download is skipped because direct Graph GET calls are disabled" 2
return
}
$encryptionSource = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
$pkgPath = $fi.DirectoryName
if($pkgPath)
{
Write-Log "Download file $($PolicyObject.JsonObject.FileName)"
$exportFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.FileName).encrypted")
$contentFileObj = Start-DownloadAppContent $PolicyObject $exportFile -GetContentFileInfoOnly
$encryptionFile = Find-AppEncryptionFile $PolicyObject $contentFileObj $encryptionSource
if($encryptionFile -and [IO.File]::Exists($encryptionFile))
{
Start-DownloadFile $contentFileObj.azureStorageUri $exportFile
if([IO.File]::Exists($exportFile))
{
Write-Log "Decrypt file"
$encryptionInfo = ConvertFrom-Json ([IO.File]::ReadAllText($encryptionFile))
if($encryptionInfo.fileEncryptionInfo)
{
$encryptionInfo = $encryptionInfo.fileEncryptionInfo
}
$destination = $pkgPath + ("\$($PolicyObject.JsonObject.FileName)" -replace 'intunewin$', 'zip')
Start-DecryptFile $exportFile $destination $encryptionInfo.encryptionKey $encryptionInfo.initializationVector
}
try { [IO.File]::Delete($exportFile) }
catch {
Write-LogError "Failed to delete exported encrypted file" $_.Exception
}
}
else
{
Write-Log "Could not find encryption file"
}
}
}
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.windowsMicrosoftEdgeApp")
{
$assignments = $SourceObject.JsonObject.assignments
foreach($assignment in $assignments)
{
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterId"
Remove-Property $assignment.target "deviceAndAppManagementAssignmentFilterType"
}
return (@{"Assignments" = $assignments})
}
elseif($PolicyObject.JsonObject.'@odata.type' -eq "#microsoft.graph.winGetApp")
{
Write-LogDebug "Wait for '$($PolicyObject.Name)' to be published"
$i = 2
Start-Sleep -s ($i)
$x = 0
while($x -lt 10)
{
$appInfo = Invoke-MSGraphAPI -Url "$($PolicyObject.PolicyType.API)/$($PolicyObject.id)" -ODataMetadata "skip" -TokenId $PolicyObject.TokenId
if($appInfo.publishingState -eq "Published")
{
Write-LogDebug "Application '$($PolicyObject.Name)' is published"
return $null
}
Start-Sleep -s ($i)
$x++
if($x -ge 5) { $i++ }
}
Write-Log "Application '$($PolicyObject.Name)' is not published. Skipping assignments" 2
return (@{"Import" = $false})
}
return $null
}
PostBulkImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
Add-ApplicationReferences $PolicyObject $SourceObject
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.windowsMobileMSI")
{
Remove-Property $PolicyObject.JsonObject "useDeviceContext"
}
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.officeSuiteApp")
{
Remove-Property $PolicyObject.JsonObject "officeConfigurationXml"
Remove-Property $PolicyObject.JsonObject "officePlatformArchitecture"
Remove-Property $PolicyObject.JsonObject "developer"
Remove-Property $PolicyObject.JsonObject "owner"
Remove-Property $PolicyObject.JsonObject "publisher"
}
elseif($PolicyObject.JsonObject.'@OData.type' -eq "#microsoft.graph.winGetApp")
{
# Immutable after creation - Graph: "The property
# 'InstallExperience' cannot be patched."
Remove-Property $PolicyObject.JsonObject "installExperience"
Remove-Property $PolicyObject.JsonObject "packageIdentifier"
Remove-Property $PolicyObject.JsonObject "manifestHash"
}
Remove-Property $PolicyObject.JsonObject "appStoreUrl"
# assignments is a navigation property (managed via /assign), not
# inline-PATCHable: "Cannot apply PATCH to navigation property
# 'assignments' on entity type mobileApp".
Remove-Property $PolicyObject.JsonObject "assignments"
Remove-Property $PolicyObject.JsonObject "assignments@odata.context"
return $null
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
# Exported app json carries a top-level @odata.id (and, when assignments
# were expanded, assignments@odata.context) - both identify the
# mobileApps entity set, which only hosts apps.
if($PolicyObject.'@odata.id' -like '*deviceAppManagement/mobileApps(*') {
return $true
}
if($PolicyObject.'assignments@odata.context' -like '*#deviceAppManagement/mobileApps(*') {
return $true
}
return $false
}
}
Class ApplicationObject : IntunePolicyBase
{
Hidden [String]$_AppTypeName = $null
Hidden [String]$_AppTypeGroup = $null
Hidden [String]$_InstallerType = $null
# Carries phase-1 → phase-2 routing state (script id → { Script; Target })
# so the orchestrator's phase-2 ApplyResult can find which install/uninstall
# target object to attach #ScriptInfo to without re-walking the script list.
Hidden [Hashtable]$_SubResourceState = $null
ApplicationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ApplicationObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "ApplicationType")
$this._PlatformName = Get-GraphApplicationPlatform $this
$this._AppTypeGroup = Get-GraphApplicationTypeGroup $this
$this._AppTypeName = (Get-GraphApplicationName $this)
$this._HasSubResourceBatch = $true
if($this.JsonObject."@OData.Type" -eq "#microsoft.graph.winGetApp") {
if($this.JsonObject.packageIdentifier -like "9*")
{
$this._InstallerType = "UWP"
}
elseif($this.JsonObject.packageIdentifier -like "X*")
{
$this._InstallerType = "Win32"
}
else
{
Write-Log "Unknown package identifier for app $($this.Name): $($this.JsonObject.packageIdentifier)" 2
$this._InstallerType = "Unknown"
}
}
Add-ObjectProperty $this "ApplicationType" { $this._AppTypeName }
Add-ObjectProperty $this "ApplicationTypeGroup" { $this._AppTypeGroup }
Add-ObjectProperty $this "InstallerType" { $this._InstallerType }
}
# Phase 1: relationships (when there are dependencies/supersedences) and the
# win32 script list (when an active install/uninstall script is referenced).
# Phase 2 follow-ups are produced by the phase-1 ApplyResult below.
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
if(([int64]($this.Object.dependentAppCount) -gt 0) -or ([int64]($this.Object.supersededAppCount) -gt 0)) {
[void]$reqs.Add([PSCustomObject]@{
Key = 'rel'
Url = "deviceAppManagement/mobileApps/$($this.Id)/relationships?`$filter=targetType%20eq%20microsoft.graph.mobileAppRelationshipType%27child%27"
})
}
if($this.Object.'@odata.type' -eq '#microsoft.graph.win32LobApp' -and
($this.Object.activeInstallScript.targetId -or $this.Object.activeUninstallScript.targetId)) {
[void]$reqs.Add([PSCustomObject]@{
Key = 'scriptlist'
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/"
})
}
return $reqs.ToArray()
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -eq 'rel') {
$deps = @()
$sups = @()
foreach($rel in @($Body.value)) {
if($rel.'@odata.type' -eq '#microsoft.graph.mobileAppDependency') {
$deps += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.dependencyType)"
}
elseif($rel.'@odata.type' -eq '#microsoft.graph.mobileAppSupersedence') {
$sups += "$($rel.targetDisplayName)|!|$($rel.targetDisplayVersion)|!|$($rel.targetId)|!|$($rel.supersedenceType)"
}
}
if($deps.Count -gt 0) {
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefDependency' -Value ($deps -join '|*|') -Force
}
if($sups.Count -gt 0) {
$this.Object | Add-Member -MemberType NoteProperty -Name '#CustomRefSupersedence' -Value ($sups -join '|*|') -Force
}
return @()
}
if($Phase -eq 1 -and $Key -eq 'scriptlist') {
if($null -eq $this._SubResourceState) { $this._SubResourceState = @{} }
$followups = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach($script in @($Body.value)) {
$target = $null
if($this.Object.activeInstallScript.targetId -eq $script.id) {
$target = $this.Object.activeInstallScript
}
elseif($this.Object.activeUninstallScript.targetId -eq $script.id) {
$target = $this.Object.activeUninstallScript
}
else {
Write-Log "Script with id $($script.id) is not referenced by active install or uninstall script. Skipping." 2
continue
}
$stateKey = "scriptcontent_$($script.id)"
$this._SubResourceState[$stateKey] = [PSCustomObject]@{ Script = $script; Target = $target }
[void]$followups.Add([PSCustomObject]@{
Key = $stateKey
Url = "deviceAppManagement/mobileApps/$($this.Id)/microsoft.graph.win32LobApp/contentVersions/$($this.Object.committedContentVersion)/scripts/$($script.id)?`$select=Id,Content"
})
}
return $followups.ToArray()
}
if($Phase -eq 2 -and $Key -like 'scriptcontent_*' -and $null -ne $this._SubResourceState) {
$state = $this._SubResourceState[$Key]
if($state) {
if($Body -and $Body.Content) {
$state.Script | Add-Member -MemberType NoteProperty -Name 'content' -Value $Body.Content -Force
}
$state.Target | Add-Member -MemberType NoteProperty -Name '#ScriptInfo' -Value $state.Script -Force
$this._SubResourceState.Remove($Key) | Out-Null
}
return @()
}
return @()
}
}
#endregion
function Start-ApplicationImportFile
{
param($PolicyObject, $PackageFile = $null)
if(-not $PolicyObject.JsonObject.'@odata.type') { return }
if($null -eq $PackageFile)
{
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
{
Write-LogDebug "Package source directory in Settings is not specified" 2
return
}
elseif([IO.Directory]::Exists($pkgPath) -eq $false)
{
Write-LogDebug "Package source directory '$($pkgPath)' does not exist" 2
return
}
$PackageFile = [IO.Path]::Combine($pkgPath, "$($PolicyObject.JsonObject.fileName)")
$packageFile2 = [IO.Path]::Combine($pkgPath, $PolicyObject.Name, "$($PolicyObject.JsonObject.fileName)")
if([IO.File]::Exists($PackageFile) -eq $false -and [IO.File]::Exists($packageFile2)) {
$PackageFile = $packageFile2
}
}
$fi = [IO.FileInfo]$PackageFile
if($fi.Exists -eq $false)
{
Write-LogDebug "Package source file $($fi.FullName) not found" 2
return
}
Write-Status "Import application package file $($fi.FullName)"
Write-Log "Import application file '$($($fi.FullName))' for $($PolicyObject.Name)"
$appType = $PolicyObject.JsonObject.'@odata.type'.Trim('#')
if($appType -eq "microsoft.graph.win32LobApp")
{
$fileEncryptionInfo = Copy-Win32LOBPackage $PackageFile $PolicyObject
}
elseif($appType -eq "microsoft.graph.windowsMobileMSI")
{
$fileEncryptionInfo = Copy-MSILOB $PackageFile $PolicyObject
}
elseif($appType -eq "microsoft.graph.windowsUniversalAppX")
{
$fileEncryptionInfo = Copy-MSIXLOB $PackageFile $PolicyObject
}
elseif($appType -eq "microsoft.graph.iosLOBApp")
{
$fileEncryptionInfo = Copy-iOSLOB $PackageFile $PolicyObject
}
elseif($appType -eq "microsoft.graph.androidLOBApp")
{
$fileEncryptionInfo = Copy-AndroidLOB $PackageFile $PolicyObject
}
else
{
Write-Log "Unsupported application type $appType. File will not be uploaded" 2
}
if((Get-SettingValue "IntuneSaveEncryptionFile") -eq $true)
{
if($fileEncryptionInfo)
{
$jsonEncryptionInfo = $fileEncryptionInfo | ConvertTo-Json -Depth 10
$pkgPath = Get-SettingValue "IntuneAppDownloadFolder" (Get-SettingValue "IntuneAppPackagesFolder")
if($pkgPath -and [IO.Directory]::Exists($pkgPath))
{
$obj = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -ODataMetadata "Minimal" -TokenId $PolicyObject._TokenID
$fullPath = [IO.Path]::Combine($pkgPath, "$($obj.displayName)_$($obj.id)_$($obj.committedContentVersion).json")
$jsonEncryptionInfo | Out-File -FilePath $fullPath -Force -Encoding utf8
}
}
}
}
function Start-ApplicationAddInstallScripts
{
param($PolicyObject, $FromAppObj)
if($FromAppObj -and ($FromAppObj.activeInstallScript."#ScriptInfo" -or $FromAppObj.activeUninstallScript."#ScriptInfo"))
{
Write-Log "Importing scripts for $($PolicyObject.displayName)"
$scriptsAdded = $false
$jsonData = @{}
$jsonData."@odata.type" = "#microsoft.graph.win32LobApp"
$jsonData."committedContentVersion" = "1"
foreach ($scriptType in @('activeInstallScript','activeUninstallScript')) {
$scriptInfo = $FromAppObj.$scriptType.'#ScriptInfo'
if (-not $scriptInfo) { continue }
Write-Log "Add $($scriptType -replace '^active','') script: $($scriptInfo.displayName)"
$json = [ordered]@{
'@odata.type' = $scriptInfo.'@odata.type'
displayName = $scriptInfo.displayName
enforceSignatureCheck = $scriptInfo.enforceSignatureCheck
runAs32Bit = $scriptInfo.runAs32Bit
content = $scriptInfo.content
} | ConvertTo-Json -Depth 10 -Compress
$scriptObject = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Method POST -Content $json -TokenId $PolicyObject._TokenID
if ($scriptObject) {
$jsonData.$scriptType = @{ targetId = $scriptObject.Id }
$scriptsAdded = $true
}
}
$i = 0
while($true)
{
$scripts = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -TokenId $PolicyObject._TokenID
if(-not $scripts)
{
Write-Log "Failed to retrieve scripts for app after adding. Skipping Install/Uninstall script config." 2
return
}
if(($scripts.value.state | Select -Unique) -eq "commitSuccess")
{
Write-Log "Scripts added successfully"
break
}
if($i -ge 12)
{
Write-Log "Install/Uninstall scripts are still not in pending state after waiting for 1 minute." 3
return
}
Write-Log "Waiting for scripts to be added..."
Start-Sleep -Seconds 5
$i++
}
if($scriptsAdded)
{
Write-Log "Add script info to app"
$json = ConvertTo-Json $jsonData -Depth 10
$status = Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.id)" -Method PATCH -Body $json -TokenId $PolicyObject._TokenID -FullResponseObject
if($status.Success)
{
Write-Log "Install/Uninstall script info updated successfully"
}
else
{
Write-Log "Failed to update Install/Uninstall script info" 2
}
}
}
}
function Add-ApplicationReferences
{
param($PolicyObject, $SourceObject)
if($SourceObject.JsonObject."#CustomRefDependency" -or $SourceObject.JsonObject."#CustomRefSupersedence")
{
Write-Log "Adding app references for $($PolicyObject.displayName)"
$depAppsInfo = $SourceObject.JsonObject."#CustomRefDependency"
$supAppsInfo = $SourceObject.JsonObject."#CustomRefSupersedence"
$releationShips = [PSCustomObject]@{
relationships = @()
}
if($depAppsInfo)
{
foreach($depApp in ($depAppsInfo -split "[|][*][|]"))
{
$appName, $appVer, $appId, $appType = $depApp -split "[|][!][|]"
if(-not $appName -or -not $appVer)
{
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
continue
}
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
if(-not $tmpApps)
{
Write-Log "No application found with name $appName" 2
continue
}
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
if(-not $tmpApp)
{
Write-Log "No $appName application found with version $appVer" 2
continue
}
elseif(($tmpApp | Measure-Object).Count -gt 1)
{
Write-Log "Multiple $appName application found with version $appVer" 2
continue
}
Write-Log "Add $appName ($appVer) to Dependency list"
$releationShips.relationships += [PSCustomObject]@{
"@odata.type" = "#microsoft.graph.mobileAppDependency"
targetId = $tmpApp.Id
dependencyType = $appType
}
}
}
if($supAppsInfo)
{
foreach($suppApp in ($supAppsInfo -split "[|][*][|]"))
{
$appName, $appVer, $appId, $appType = $suppApp -split "[|][!][|]"
if(-not $appName -or -not $appVer)
{
Write-Log "Could not get Name and Version from string: $($PolicyObject.displayName)" 2
continue
}
$tmpApps = (Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps?`$filter=displayName eq '$appName'" -TokenId $PolicyObject.TokenId).value
if(-not $tmpApps)
{
Write-Log "No application found with name $appName" 2
continue
}
$tmpApp = $tmpApps | Where-Object displayVersion -eq $appVer
if(-not $tmpApp)
{
Write-Log "No $appName application found with version $appVer" 2
continue
}
elseif(($tmpApp | Measure-Object).Count -gt 1)
{
Write-Log "Multiple $appName application found with version $appVer" 2
continue
}
Write-Log "Add $appName ($appVer) to Supersedence list"
$releationShips.relationships += [PSCustomObject]@{
"@odata.type" = "#microsoft.graph.mobileAppSupersedence"
targetId = $tmpApp.Id
supersedenceType = $appType
}
}
}
if($releationShips.relationships.Count -gt 0)
{
$json = Update-JsonForEnvironment (ConvertTo-Json $releationShips -Depth 20) $PolicyObject $PolicyObject.TokenId
Write-Log "Update app references"
Invoke-MSGraphAPI -Url "deviceAppManagement/mobileApps/$($PolicyObject.Id)/updateRelationships" -Method "POST" -Body $json
}
}
}
#########################################################################################
#
# iOS LOB App Provisioning Configurations
#
#########################################################################################
#
# Apple-issued provisioning profiles (.mobileprovision files) that travel
# alongside iOS LOB apps. Without these, signed LOB apps stop launching when
# the embedded profile expires. Endpoint at
# /deviceAppManagement/iosLobAppProvisioningConfigurations.
#
# `payload` (Edm.Binary) carries the base64-encoded .mobileprovision file;
# it round-trips through JSON export/import as the payload string.
# region IosLobAppProvisioningConfigurationsType
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class IosLobAppProvisioningConfigurationsType : IntunePolicyTypeBase
{
IosLobAppProvisioningConfigurationsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ApplicationsGroup")
$this._PolicyName = "iOS app provisioning profiles"
$this._ID = "IosLobAppProvisioningConfigurations"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (iosLobAppProvisioningConfigurations is iOS-only).
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
$this._API = "deviceAppManagement/iosLobAppProvisioningConfigurations"
# No dedicated icon yet — fall back to Applications. Tracked in TODO
# under the icons-for-new-APIs entry.
$this._Icon = "Applications"
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
# version + expirationDateTime are derived from the embedded
# .mobileprovision; createdDateTime / lastModifiedDateTime are
# server-set. Strip on POST/PATCH.
$this._PropertiesToRemove = @('version','expirationDateTime')
$this._PropertiesToRemoveForUpdate = @('version','expirationDateTime','payload','payloadFileName')
# Default `assignments` shape with simple {target} — no overrides
# needed beyond the inherited defaults.
$this._ImportOrder = 90
$this._ObjectClass = "IosLobAppProvisioningConfigurationObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class IosLobAppProvisioningConfigurationObject : IntunePolicyBase
{
IosLobAppProvisioningConfigurationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
IosLobAppProvisioningConfigurationObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "IosLobAppProvisioningConfigurationsType")
$this._PlatformName = Get-LanguageString "Platform.iOS" -IgnoreMissing
if(-not $this._PlatformName) { $this._PlatformName = "iOS/iPadOS" }
}
}
+85
View File
@@ -0,0 +1,85 @@
#ImportOrder 32
class IntuneAssignmentsProviderBase
{
[string] $Name
[string] $Value
[string] $OptionsXaml
IntuneAssignmentsProviderBase([string]$name, [string]$value, [string]$optionsXaml)
{
$this.Name = $name
$this.Value = $value
$this.OptionsXaml = $optionsXaml
}
[bool] Validate() { return $true }
[void] SaveSettings() { }
[object[]] GetAssignments() { return @() }
[string] ToString() { return $this.Name }
}
class IntuneAssignmentsFolderProvider : IntuneAssignmentsProviderBase
{
[string] $ExportPath
IntuneAssignmentsFolderProvider() : base(
"From Folder",
"folder",
"IntuneToolsAssignmentsFolderOptions"
) {}
[bool] Validate()
{
if([string]::IsNullOrWhiteSpace($this.ExportPath) -or -not [IO.Directory]::Exists($this.ExportPath))
{
throw "Select a valid folder containing exported objects"
}
return $true
}
[void] SaveSettings()
{
Save-SettingStoreValue "IntuneAssignments" "ExportPath" $this.ExportPath
}
[object[]] GetAssignments()
{
return (Get-IntuneAssignmentsFromFolder $this.ExportPath)
}
}
class IntuneAssignmentsIntuneProvider : IntuneAssignmentsProviderBase
{
IntuneAssignmentsIntuneProvider() : base(
"From Intune",
"intune",
"IntuneToolsAssignmentsIntuneOptions"
) {}
[bool] Validate()
{
# Ask the active auth provider whether a session exists, not the MSAL-specific
# $script:MSALTokens registry. The latter is empty when MgGraph is the active
# provider, so this method incorrectly blocked signed-in MgGraph users.
$signedIn = $false
try {
$provider = Get-AuthProvider
if($provider) {
$userInfo = $provider.GetUserInfo(0)
if($userInfo) { $signedIn = $true }
}
} catch { }
if(-not $signedIn)
{
throw "You must be logged in to read assignments from Intune"
}
return $true
}
[object[]] GetAssignments()
{
return (Get-IntuneAssignmentsFromIntune)
}
}
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
#ImportOrder 220
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class ComplianceGroup : IntunePolicyGroupBase
{
ComplianceGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "Compliance"
$this._Name = "Compliance"
$this._Icon = "CompliancePolicies"
}
}
#########################################################################################
#
# Device Compliance
#
#########################################################################################
# region Device Compliance
class DeviceComplianceType : IntunePolicyTypeBase
{
DeviceComplianceType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
$this._PolicyName = "Compliance Policy"
$this._ID = "CompliancePolicies"
$this._API = "deviceManagement/deviceCompliancePolicies"
# This endpoint answers HTTP 400 to startswith() on displayName
# (verified 2026-08-27; 'displayName eq' works, prefix search does not),
# so name searches filter client-side instead.
$this._SupportsNameFilter = $false
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._Expand = "scheduledActionsForRule(`$expand=scheduledActionConfigurations)"
# "Locations" (v3's deprecated Intune managementConditions type) is not a
# PolicyType here, so listing it resolved to nothing on import.
$this._Dependencies = @("Notifications","ComplianceScripts")
$this._ObjectClass = "DeviceComplianceObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
foreach($scheduledActionsForRule in $PolicyObject.JsonObject.scheduledActionsForRule)
{
foreach($scheduledActionConfiguration in $scheduledActionsForRule.scheduledActionConfigurations)
{
foreach($notificationMessageCCGroup in $scheduledActionConfiguration.notificationMessageCCList)
{
Add-GraphMigrationObject $notificationMessageCCGroup "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
}
}
}
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
$api = "deviceManagement/deviceCompliancePolicies/$($PolicyObject.Id)/scheduleActionsForRules"
$tmpObj = [PSCustomObject]@{
deviceComplianceScheduledActionForRules = $PolicyObject.JsonObject.scheduledActionsForRule
}
$json = ConvertTo-Json $tmpObj -Depth 20
Invoke-MSGraphAPI -Url $api -Content $json -HttpMethod "POST" -TokenId $PolicyObject._TokenId | Out-Null
Remove-Property $PolicyObject.JsonObject "scheduledActionsForRule"
return (@{})
}
}
Class DeviceComplianceObject : IntunePolicyBase
{
DeviceComplianceObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DeviceComplianceObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "DeviceComplianceType")
}
}
#########################################################################################
#
# Device Compliance V2
#
#########################################################################################
Class DeviceComplianceV2Type : IntunePolicyTypeBase
{
DeviceComplianceV2Type() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
# Its own name so a mixed Compliance list tells V1 from V2 without a
# separate "Policy base" column.
$this._PolicyName = "Compliance Policy (Settings Catalog)"
$this._PolicyBaseName = "Compliance Policy V2"
$this._APITitle = "Compliance Policy (Linux)"
$this._ID = "CompliancePoliciesV2"
$this._API = "deviceManagement/compliancePolicies"
$this._PropertiesToRemove = @('settingCount')
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._NameProperty = "Name"
$this._Expand = "settings"
$this._ObjectClass = "DeviceComplianceV2Object"
$this._Icon = "CompliancePolicies"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
# V2 compliance runs on the settings-catalog engine: updates must PUT
# the full body (including settings); PATCH with settings is rejected.
return @{ "Method" = "PUT" }
}
}
Class DeviceComplianceV2Object : IntunePolicyBase
{
DeviceComplianceV2Object([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DeviceComplianceV2Object() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "DeviceComplianceV2Type")
}
}
#########################################################################################
#
# Compliance Scripts
#
#########################################################################################
Class ComplianceScriptsType : IntunePolicyTypeBase
{
ComplianceScriptsType() : Base()
{
([ComplianceScriptsType]$this).Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
$this._APITitle = "Compliance Scripts"
$this._PolicyName = "Compliance Script"
$this._ID = "ComplianceScripts"
$this._API = "deviceManagement/deviceComplianceScripts"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "ComplianceScriptObject"
$this._Icon = "Scripts"
# Graph rejects PATCHing the read-only version back ("Invalid property
# name: Version").
$this._PropertiesToRemoveForUpdate = @('version')
# Custom compliance scripts are REFERENCED by compliance policies, not
# assigned to devices - the portal has no Assignments blade and Graph's
# /assign action rejects the request (400 "Action parameters do not
# contain parameter 'deviceHealthScriptAssignments'").
$this._SupportsAssignments = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class ComplianceScriptObject : IntunePolicyBase
{
ComplianceScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ComplianceScriptObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.windows10AndLater"
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsType")
}
}
#########################################################################################
#
# Compliance Scripts - Linux
#
#########################################################################################
Class ComplianceScriptsLinuxType : ReusableSettingsTypeBase
{
ComplianceScriptsLinuxType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
$this._APITitle = "Compliance Scripts (Linux)"
$this._PolicyName = "Compliance Script"
$this._ID = "ComplianceScriptsScriptsLinux"
$this._QueryList = "?`$filter=settingDefinitionId eq 'linux_customcompliance_discoveryscript_reusablesetting'"
# Reusable settings carry no roleScopeTagIds in the Graph schema.
$this._ScopeTagProperty = ""
$this._ObjectClass = "ComplianceScriptLinuxObject"
$this._Icon = "Scripts"
$this._Folder = "ReusableSettings"
$this._PolicyTypeOrder = 140
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
if($PolicyObject.settingDefinitionId -eq 'linux_customcompliance_discoveryscript_reusablesetting') {
return $true
}
return $false
}
}
Class ComplianceScriptLinuxObject : ReusableSettingsObjectBase
{
ComplianceScriptLinuxObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ComplianceScriptLinuxObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "ComplianceScriptsLinuxType")
$this._PlatformName = Get-LanguageString "Platform.linux" -IgnoreMissing
}
}
#########################################################################################
#
# Compliance Notifications
#
#########################################################################################
Class NotificationsType : IntunePolicyTypeBase
{
NotificationsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ComplianceGroup")
$this._PolicyName = "Notifications"
$this._ID = "Notifications"
$this._HasPlatform = $false
$this._API = "deviceManagement/notificationMessageTemplates"
#$this._QueryList = "?`$filter=displayName ne 'EnrollmentNotificationInternalMEO'"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
$this._ObjectClass = "NotificationObject"
$this._ImportOrder = 40
$this._Expand = "localizedNotificationMessages"
$this._ExpandAssignmentsList = $false
# notificationMessageTemplate has no `assignments` navigation property at
# all (its only nav is localizedNotificationMessages), so both the nav GET
# and ?$expand=assignments return 400. Notification templates are targeted
# from compliance policies, not assigned - don't ask for assignments.
$this._SupportsAssignments = $false
# notificationMessageTemplate has no description property in Graph.
$this._HasDescription = $false
# localizedNotificationMessages is a navigation property - PATCHing it
# inline is rejected; messages are managed on their own sub-endpoint.
$this._PropertiesToRemoveForUpdate = @('localizedNotificationMessages','defaultLocale')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
Remove-Property $PolicyObject.JsonObject "defaultLocale"
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages"
Remove-Property $PolicyObject.JsonObject "localizedNotificationMessages@odata.context"
return $null
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
$this.UpdateNotificationMessages($PolicyObject, $SourceObject.JsonObject.localizedNotificationMessages)
}
Hidden UpdateNotificationMessages($PolicyObject, $localizedNotificationMessages)
{
if(-not $localizedNotificationMessages) {
return
}
$updated = $false
foreach($localizedNotificationMessage in $localizedNotificationMessages)
{
Remove-GraphPropertiesForImport $this $localizedNotificationMessage
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)/localizedNotificationMessages" -Body ($localizedNotificationMessage | ConvertTo-Json -Depth 20) -Method "POST" -FullResponseObject
if($response.Success) {
Write-log "Notification message '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale)) added successfully"
$updated = $true
}
else {
Write-log "Failed to add notification message: '$($localizedNotificationMessage.subject)' ($($localizedNotificationMessage.locale))" 3
}
}
if($updated) {
[void]$PolicyObject.Get()
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'displayName' -eq "EnrollmentNotificationInternalMEO") { return $false } # Skip built in
return (([IntunePolicyTypeBase]$this).CheckPolicy($PolicyObject))
}
}
Class NotificationObject : IntunePolicyBase
{
NotificationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
NotificationObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "NotificationsType")
}
}
+524
View File
@@ -0,0 +1,524 @@
#ImportOrder 220
#########################################################################################
#
# Apple Update Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class ConditionalAccessGroup : IntunePolicyGroupBase
{
ConditionalAccessGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "ConditionalAccess"
$this._Name = "Conditional Access"
$this._Icon = "ConditionalAccess"
}
}
#########################################################################################
#
# Conditional Access Policies
#
#########################################################################################
# region Conditional Access Policies
class ConditionalAccessType : IntunePolicyTypeBase
{
ConditionalAccessType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
$this._PolicyName = "Conditional Access"
$this._ID = "ConditionalAccess"
$this._HasPlatform = $false
$this._API = "identity/conditionalAccess/policies"
# Entra object - no roleScopeTagIds in the Graph schema (a PATCH
# no-ops), so no scope-tag support.
$this._ScopeTagProperty = ""
$this._Dependencies = @("NamedLocations","Applications","TermsOfUse","AuthenticationStrengths","AssignmentFilters")
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
$this._ObjectClass = "ConditionalAccessObject"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
# Tenant-lockout guard: rewrite the imported policy's state per the
# ConditionalAccessState setting (default: disabled). Logic lives in
# Internal/IntuneManager.ps1 so it is unit-testable.
Set-CAPolicyImportState $PolicyObject
if($PolicyObject.grantControls.authenticationStrength)
{
$PolicyObject.JsonObject.grantControls.operator = "AND"
#$tmpObj = Get-GraphObjectFromFile $file
#$authSetting = [PSCustomObject]@{
# id = $tmpObj.grantControls.authenticationStrength.id
#}
#$PolicyObject.JsonObject.grantControls.authenticationStrength = $authSetting
}
if($PolicyObject.JsonObject.sessionControls.disableResilienceDefaults -eq $false)
{
$PolicyObject.JsonObject.sessionControls.disableResilienceDefaults = $null
}
return $null
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
$ids = @()
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeGroups + $PolicyObject.JsonObject.conditions.users.excludeGroups))
{
if($id -in $ids) { continue }
elseif($id -eq "GuestsOrExternalUsers") { continue }
elseif($id -eq "All") { continue }
elseif($id -eq "None") { continue }
$ids += $id
Add-GraphMigrationObject $id "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
}
foreach($id in ($PolicyObject.JsonObject.conditions.users.includeUsers + $PolicyObject.JsonObject.conditions.users.excludeUsers))
{
if($id -in $ids) { continue }
elseif($id -eq "GuestsOrExternalUsers") { continue }
elseif($id -eq "All") { continue }
elseif($id -eq "None") { continue }
$ids += $id
Add-GraphMigrationObject $id "users" "User" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
}
}
}
Class ConditionalAccessObject : IntunePolicyBase
{
ConditionalAccessObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ConditionalAccessObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "ConditionalAccessType")
}
}
#########################################################################################
#
# Authentication Strengths
#
#########################################################################################
# region Authentication Strengths
class AuthenticationStrengthsType : IntunePolicyTypeBase
{
AuthenticationStrengthsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
$this._PolicyName = "Authentication Strengths"
$this._ID = "AuthenticationStrengths"
$this._HasPlatform = $false
$this._API = "identity/conditionalAccess/authenticationStrengths/policies"
$this._ImportOrder = 45
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
$this._ObjectClass = "AuthenticationStrengthObject"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._TopItems = 0
$this._Icon = "ConditionalAccess"
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
if($PolicyObject.Object.policyType -ne "custom")
{
Write-Log "Built-in Authentication Strength objects cannot be imported" 2
@{ "Import" = $false }
}
return $null
}
}
Class AuthenticationStrengthObject : IntunePolicyBase
{
AuthenticationStrengthObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AuthenticationStrengthObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AuthenticationStrengthsType")
}
}
#########################################################################################
#
# Authentication Context
#
#########################################################################################
# region Authentication Context
class AuthenticationContextType : IntunePolicyTypeBase
{
AuthenticationContextType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
$this._PolicyName = "Authentication Context"
$this._ID = "AuthenticationContext"
$this._HasPlatform = $false
$this._HasModified = $false
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "identity/conditionalAccess/authenticationContextClassReferences"
$this._PropertiesToRemove = @("@odata.type")
$this._SkipRemoveProperties = @('Id')
$this._ImportOrder = 46
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
$this._ObjectClass = "AuthenticationContextObject"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._TopItems = 0
$this._Icon = "ConditionalAccess"
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class AuthenticationContextObject : IntunePolicyBase
{
AuthenticationContextObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AuthenticationContextObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AuthenticationContextType")
}
}
#########################################################################################
#
# Authentication Context
#
#########################################################################################
# region Authentication Context
class NamedLocationType : IntunePolicyTypeBase
{
NamedLocationType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
$this._PolicyName = "Named Locations"
$this._ID = "NamedLocations"
$this._HasPlatform = $false
$this._API = "identity/conditionalAccess/namedLocations"
# Entra object - no roleScopeTagIds in the Graph schema.
$this._ScopeTagProperty = ""
$this._ImportOrder = 50
$this._Permissions = @("Policy.ReadWrite.ConditionalAccess")
$this._ObjectClass = "NamedLocationObject"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class NamedLocationObject : IntunePolicyBase
{
NamedLocationObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
NamedLocationObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "NamedLocationType")
}
}
#########################################################################################
#
# Terms of use
#
#########################################################################################
# region Terms of use
class TermsOfUseType : IntunePolicyTypeBase
{
TermsOfUseType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ConditionalAccessGroup")
$this._PolicyName = "Terms of use"
$this._ID = "TermsOfUse"
$this._HasPlatform = $false
$this._HasModified = $false
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "identityGovernance/termsOfUse/agreements"
# Entra object - no roleScopeTagIds in the Graph schema.
$this._ScopeTagProperty = ""
$this._ImportOrder = 75
$this._Expand = "files"
$this._QueryList = "?`$expand=files"
$this._Permissions = @("Agreement.ReadWrite.All")
$this._ObjectClass = "TermsOfUseObject"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
$pkgPath = Get-SettingValue "IntuneAppPackagesFolder"
if(-not $pkgPath -or [IO.Directory]::Exists($pkgPath) -eq $false)
{
Write-Log "Intune app directory is either missing or does not exist" 2
}
# The agreement document is resolved in this order, per localization:
# 1. fileData.data already on the object - an export carries the PDF
# inline, fetched per localization by the sub-resource contract.
# 2. <fileName> next to the exported json (FileInfo), then in the app
# packages folder - for a json that was exported without the data.
# 3. The source object, for an in-memory COPY (below).
# Refusing rather than proceeding matters: an agreement created without
# its document lists fine but /file, /files and /file/localizations all
# 404, and a later list with $expand=files returns 500 for the WHOLE
# collection. Three of those were found in the test tenant on 2026-09-06
# and had to be deleted by hand.
#
# An earlier version of this block required the PDF on disk whenever
# FileInfo was set and ignored the embedded data. That only held together
# because Clone() used to drop FileInfo, so an import from disk never
# reached it with FileInfo populated. Once Clone() kept FileInfo, every
# import of an export with an inline PDF was refused.
$hasData = { param($f) ($f.PSObject.Properties['fileData'] -and $f.fileData -and
$f.fileData.PSObject.Properties['data'] -and $f.fileData.data) }
if($PolicyObject.FileInfo) {
foreach($file in $PolicyObject.Object.Files)
{
if(& $hasData $file) { continue }
$pdfFile = $null
if($PolicyObject.FileInfo.Directory.FullName)
{
$pdfFile = [IO.Path]::Combine($PolicyObject.FileInfo.Directory.FullName, "$($file.fileName)")
}
if(($null -eq $pdfFile -or [IO.File]::Exists($pdfFile) -eq $false) -and $pkgPath)
{
$pdfFile = [IO.Path]::Combine($pkgPath, "$($file.fileName)")
}
if($pdfFile -and [IO.File]::Exists($pdfFile))
{
Write-Log "Add file data: $pdfFile"
$bytes = [IO.File]::ReadAllBytes($pdfFile)
$file | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = [Convert]::ToBase64String($bytes) }) -Force
}
else
{
Write-Log "Terms of use file $($file.fileName) not found next to the export or in the app packages folder" 2
}
}
}
$touFiles = @($PolicyObject.Object.Files)
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
if($touFiles.Count -gt 0 -and $touMissing.Count -gt 0)
{
$touSource = $PolicyObject._ClonedFromObject
if($touSource -and $touSource.Id)
{
Write-Log "Terms of use '$($PolicyObject.Name)': agreement file(s) not loaded - fetching them from the source object"
$touTokenId = 0
if($touSource.PSObject.Properties['_TokenId'] -and $null -ne $touSource._TokenId) {
$touTokenId = [int]$touSource._TokenId
}
elseif($PolicyObject.PSObject.Properties['_TokenId'] -and $null -ne $PolicyObject._TokenId) {
$touTokenId = [int]$PolicyObject._TokenId
}
try { Invoke-PolicySubresourceFetch -Policies @($touSource) -TokenId $touTokenId | Out-Null }
catch { Write-LogError "Failed to fetch terms of use file data from the source object" $_.Exception }
foreach($touFile in $touMissing)
{
$srcFile = @($touSource.Object.Files) | Where-Object { $_.id -eq $touFile.id } | Select-Object -First 1
if($srcFile -and $srcFile.PSObject.Properties['fileData'] -and $srcFile.fileData -and $srcFile.fileData.data)
{
$touFile | Add-Member -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $srcFile.fileData.data }) -Force
}
}
$touMissing = @($touFiles | Where-Object { -not (& $hasData $_) })
}
}
if($touFiles.Count -eq 0 -or $touMissing.Count -gt 0)
{
Write-Log "Terms of use '$($PolicyObject.Name)': the agreement document is missing and could not be loaded from the source. The object will not be imported - creating it would leave an agreement with no document, which breaks the whole Terms of Use list." 2
return @{"Import" = $false}
}
return $null
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
if(-not $PathToFile) { return }
$fi = [IO.FileInfo]$PathToFile
# File binary was fetched into fileData.data by TermsOfUseObject's
# sub-resource contract during hydration. Write each to disk; no re-fetch.
foreach($file in @($PolicyObject.Object.Files))
{
$data = $null
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
$data = $file.fileData.data
}
if($data)
{
Write-Log "Save file $($file.FileName)"
$fileName = [IO.Path]::Combine($fi.DirectoryName, "$($file.FileName)")
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($data))
}
}
}
}
Class TermsOfUseObject : IntunePolicyBase
{
# Maps each in-flight file-data request key back to its file object so
# ApplySubResourceBatchResult can attach the fetched binary.
Hidden [Hashtable]$_SubResourceState = $null
TermsOfUseObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
TermsOfUseObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "TermsOfUseType")
# Agreement file binaries aren't in the body; fetch each localization's
# fileData via the sub-resource contract.
$this._HasSubResourceBatch = $true
}
# The agreements/<id>/file/localizations('<fid>')/fileData/data API lives
# ONLY here — was previously duplicated in Sync-BulkExportTermsOfUseFiles
# (Internal/PolicyHydrateExtras.ps1) and TermsOfUseType.PostExportCommand.
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
$this._SubResourceState = @{}
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach($file in @($this.Object.Files)) {
if(-not $file.id) { continue }
$existing = $null
if($file.PSObject.Properties['fileData'] -and $file.fileData -and $file.fileData.PSObject.Properties['data']) {
$existing = $file.fileData.data
}
if($existing) { continue }
$key = "toufile_$($file.id)"
$this._SubResourceState[$key] = $file
[void]$reqs.Add([PSCustomObject]@{
Key = $key
Url = "agreements/$($this.Id)/file/localizations('$($file.id)')/fileData/data"
})
}
return $reqs.ToArray()
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -ne 1 -or $null -eq $this._SubResourceState -or -not $this._SubResourceState.ContainsKey($Key)) { return @() }
$file = $this._SubResourceState[$Key]
$data = $null
if($Body -is [string]) { $data = $Body }
elseif($Body -and $Body.PSObject.Properties['value']) { $data = $Body.value }
elseif($Body -and $Body.PSObject.Properties['data']) { $data = $Body.data }
if($data) {
if($file.PSObject.Properties['fileData'] -and $file.fileData) {
if($file.fileData.PSObject.Properties['data']) { $file.fileData.data = $data }
else { $file.fileData | Add-Member -MemberType NoteProperty -Name 'data' -Value $data -Force }
}
else {
Add-Member -InputObject $file -MemberType NoteProperty -Name 'fileData' -Value ([PSCustomObject]@{ data = $data }) -Force
}
}
return @()
}
}
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
#ImportOrder 205
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class EndpointSecurityGroup : IntunePolicyGroupBase
{
EndpointSecurityGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "EndpointSecurity"
$this._Name = "Endpoint Security"
$this._Icon = "EndpointSecurity"
$this._ExtraColumns = @("TemplateFamily=Parent type") # two of three members supply it
}
}
#########################################################################################
#
# Intents
#
#########################################################################################
# region Intents
class EndpointSecurityType : IntunePolicyTypeBase
{
EndpointSecurityType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
$this._PolicyName = "Endpoint Security"
$this._APITitle = "Endpoint Security (Intents)"
$this._ID = "EndpointSecurity"
$this._API = "deviceManagement/intents"
$this._PolicyBaseName = "Intents"
$this._PropertiesToRemove = @('Settings','@OData.Type')
# Graph: "Properties not patchable specified: IsAssigned,
# IsMigratingToConfigurationPolicy, TemplateId". Settings are updated
# via the /updateSettings action, not the intent PATCH.
$this._PropertiesToRemoveForUpdate = @('isAssigned','isMigratingToConfigurationPolicy','templateId','settings')
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._SubTypeColumn = "PolicyName=Type" # per-row template name (Antivirus, Firewall, ...)
$this._ExtraColumns = @("TemplateFamily=Parent type")
$this._Expand = "Settings"
$this._Icon = "EndpointSecurity"
$this._Dependencies = @("ReusableSettings")
$this._ObjectClass = "IntentObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]GetCompareConfig()
{
return @{
Prop = "settings"
GetKey = { param($s) "$($s.definitionId)" }
GetValue = { param($s) Get-IntentSettingValue $s }
GetCategory = { param($s) Get-IntentSettingCategory $s }
}
}
Hidden [PSCustomObject]GetTemplate($TemplateId)
{
# Tag the baseline-template cache with TenantCache_<tenantId> so it gets wiped by
# Clear-TenantCache on disconnect — previous code stored it untagged and the
# previous tenant's templates would leak across tenant switches.
$tenantId = $script:OrganizationId
$cacheId = "BaseLineTemplates_$tenantId"
$baseLineTemplates = Get-CacheObject $cacheId
if(-not $baseLineTemplates)
{
$baseLineTemplates = (Invoke-MSGraphAPI -Url "/deviceManagement/templates").Value
if($baseLineTemplates) {
Set-CacheObject $cacheId $baseLineTemplates "TenantCache_$tenantId"
}
}
if(-not $baseLineTemplates) { return $null}
return ($baseLineTemplates | Where-Object Id -eq $TemplateId)
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
return (@{
"API"="deviceManagement/templates/$($PolicyObject.JsonObject.templateId)/createInstance"
})
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
$this.UpdateSettings($PolicyObject, $SourceObject.JsonObject.Settings)
}
Hidden UpdateSettings($PolicyObject, $Settings)
{
if(($Settings | Measure-Object).Count -eq 0) { return }
$clonedSettings = @()
$Settings | ConvertTo-Json -Depth 50 | ConvertFrom-Json | ForEach-Object { $clonedSettings += $_ }
$newSettings = ([HashTable]@{
"settings" = $clonedSettings
})
Remove-GraphPropertiesForImport $PolicyObject $newSettings.Settings -KeepProperties "@odata.type"
$response = Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.id)/updateSettings" -Body ($newSettings | ConvertTo-Json -Depth 50) -Method "POST" -FullResponseObject -TokenId $PolicyObject._TokenID
if($response.Success) {
Write-Log "Settings updated successfully"
}
}
}
class IntentObject : IntunePolicyBase
{
Hidden [PSCustomObject]$_BaselineTemplate = $null
IntentObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
IntentObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "EndpointSecurityType")
if($this._PolicyType -and $this.JsonObject.templateId) {
$this._BaselineTemplate = $this._PolicyType.GetTemplate($this.JsonObject.templateId)
if($this._BaselineTemplate) {
Add-ObjectProperty $this "BaselineTemplate" { $this._BaselineTemplate }
Add-ObjectProperty $this "TemplateFamily" { (Get-EndpointSecurityCategoryName (?: ($this.BaselineTemplate.templateSubtype -eq "none") $this.BaselineTemplate.templateType $this.BaselineTemplate.templateSubtype)) } # ToDo: Get actual language string
Add-ObjectProperty $this "Category" { $this.TemplateFamily }
Add-ObjectProperty $this "TemplateVersion" { $this.BaselineTemplate.versionInfo }
$this._PlatformName = Get-LanguageString "Platform.$($this._BaselineTemplate.platformType)" -IgnoreMissing
}
}
$this._PolicyName = ?? $this.BaselineTemplate.displayName $this._PolicyType.PolicyBaseType
}
}
function Get-EndpointSecurityCategoryName
{
param($TemplateType)
if(-not $TemplateType)
{
Write-Log "Get-EndpointSecurityCategoryName called with empty Category" 2
return
}
$returnString = $null
if($TemplateType.StartsWith("endpointSecurity"))
{
$TemplateType = $TemplateType.Substring(16)
}
if($TemplateType -eq "none")
{
return ""
}
elseif($TemplateType -eq "accountProtection")
{
$returnString = Get-LanguageString "SecurityTemplate.accountProtection"
}
elseif($TemplateType -eq "antivirus")
{
$returnString = Get-LanguageString "SecurityTemplate.antivirus"
}
elseif($TemplateType -eq "diskEncryption")
{
$returnString = Get-LanguageString "SecurityTemplate.diskEncryption"
}
elseif($TemplateType -eq "endpointDetectionReponse" -or $TemplateType -eq "EndpointDetectionAndResponse")
{
$returnString = Get-LanguageString "SecurityTemplate.eDR"
}
elseif($TemplateType -eq "attackSurfaceReduction")
{
$returnString = Get-LanguageString "SecurityTemplate.aSR"
}
elseif($TemplateType -eq "attackSurfaceReduction")
{
$returnString = Get-LanguageString "SecurityTemplate.aSR"
}
elseif($TemplateType -eq "firewall")
{
$returnString = Get-LanguageString "SecurityTemplate.firewall"
}
elseif($TemplateType -eq "applicationControl")
{
$returnString = Get-LanguageString "PolicyType.applicationControl"
}
elseif($TemplateType -eq "securityBaseline" -or
$TemplateType -eq "advancedThreatProtectionSecurityBaseline" -or
$TemplateType -eq "microsoftEdgeSecurityBaseline" -or
$TemplateType -eq "baseline")
{
$returnString = Get-LanguageString "Titles.securityBaselines"
}
elseif($TemplateType -eq "enrollmentConfiguration")
{
$returnString = Get-LanguageString "SettingDetails.enrollment"
}
if([String]::IsNullOrEmpty($returnString))
{
Write-Log "Could not translate templateSubtype $TemplateType" 2
return $TemplateType
}
return $returnString
}
#endregion
#########################################################################################
#
# Settings Catalog
#
#########################################################################################
# region Settings catalog
class EndpointSecuritySettingsCatalogType : SettingsCatalogTypeBase
{
EndpointSecuritySettingsCatalogType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
$this._ID = "EndpointSecuritySettingsCatalog"
# The template version a policy was created from - how you spot an
# antivirus or baseline policy still on a superseded template.
$this._ExtraColumns = @("Object.templateReference.templateDisplayVersion=Template version")
$this._APITitle = "Endpoint Security (Settings Catalog)"
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'baseline' or templateReference/templateFamily eq 'endpointSecurityAccountProtection' or templateReference/templateFamily eq 'endpointSecurityAntivirus' or templateReference/templateFamily eq 'endpointSecurityDiskEncryption' or templateReference/templateFamily eq 'endpointSecurityEndpointDetectionAndResponse' or templateReference/templateFamily eq 'endpointSecurityAttackSurfaceReduction' or templateReference/templateFamily eq 'endpointSecurityFirewall' or templateReference/templateFamily eq 'endpointSecurityApplicationControl'"
$this._Icon = "EndpointSecurity"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 100
}
}
#########################################################################################
#
# Reusable Settings
#
#########################################################################################
# region Reusable Settings
class ReusableSettingsEndpointSecurityType : ReusableSettingsTypeBase
{
ReusableSettingsEndpointSecurityType() : Base()
{
([ReusableSettingsEndpointSecurityType]$this).Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "EndpointSecurityGroup")
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security.
$this._QueryList = "?`$filter=settingDefinitionId ne 'linux_customcompliance_discoveryscript_reusablesetting'"
$this._ID = "ReusableSettingsEndpointSecurity"
$this._HasPlatform = $false
$this._Folder = "ReusableSettings"
$this._ObjectClass = "ReusableSettingEndpointSecurityObject"
$this._ImportOrder = 75
$this._PolicyTypeOrder = 145
$this._Icon = "EndpointSecurity"
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementReusablePolicySetting") { return $false }
# !!! For now...Should check actual supported settingDefinitionId for Endpoint Security. deviceConfigurationScripts
if($PolicyObject.settingDefinitionId -ne 'linux_customcompliance_discoveryscript_reusablesetting') {
return $true
}
return $false
}
}
Class ReusableSettingEndpointSecurityObject : ReusableSettingsObjectBase
{
ReusableSettingEndpointSecurityObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ReusableSettingEndpointSecurityObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "ReusableSettingsEndpointSecurityType")
}
}
#endregion
+692
View File
@@ -0,0 +1,692 @@
#ImportOrder 220
#########################################################################################
#
# Script Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class DeviceEnrollmentGroup : IntunePolicyGroupBase
{
DeviceEnrollmentGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "DeviceEnrollments"
$this._Name = "Device enrollment"
$this._Icon = "WindowsEnrollments"
}
}
#########################################################################################
#
# Autopilot
#
#########################################################################################
# region Autopilot
class AutopilotType : IntunePolicyTypeBase
{
AutopilotType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._PolicyName = "Autopilot"
$this._ID = "Autopilot"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (windowsAutopilotDeploymentProfiles is
# Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
$this._API = "deviceManagement/windowsAutopilotDeploymentProfiles"
$this._CopyDefaultName = "%Name% Copy"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
$this._ObjectClass = "AutopilotObject"
$this._PropertiesToRemoveForUpdate = @('managementServiceAppId')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
{
Write-Log "Delete AutoPilot profile assignments"
foreach($assignment in $PolicyObject.Assignments)
{
if($assignment.Source -ne "direct") { continue }
$api = "$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)/assignments/$($assignment.Id)"
$repsone = Invoke-MSGraphAPI -Url $api -HttpMethod "DELETE" -TokenId $PolicyObject._TokenId -FullResponseObject
if($repsone.Success)
{
Write-LogDebug "Assignemnt with Id $($assignment.Id) deleted successfully"
}
}
return $null
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
return (Add-GraphAssignmentsToObject $PolicyObject $SourceObject)
}
}
Class AutopilotObject : IntunePolicyBase
{
AutopilotObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AutopilotObject() : Base()
{
$this.Init()
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AutopilotType")
}
}
#########################################################################################
#
# Device Enrollment
#
#########################################################################################
# region Device Enrollment
# DeviceEnrollmentType is the SHARED BASE for everything served by
# /deviceManagement/deviceEnrollmentConfigurations. The Graph endpoint returns
# eight subtypes of an abstract base type (per the schema):
# deviceEnrollmentLimitConfiguration ('limit' / 'defaultLimit')
# deviceEnrollmentPlatformRestrictionConfiguration ('singlePlatformRestriction')
# deviceEnrollmentPlatformRestrictionsConfiguration ('platformRestrictions' / 'defaultPlatformRestrictions')
# deviceEnrollmentWindowsHelloForBusinessConfiguration('windowsHelloForBusiness' / 'defaultWindowsHelloForBusiness')
# windows10EnrollmentCompletionPageConfiguration ('windows10EnrollmentCompletionPageConfiguration' / 'defaultWindows10EnrollmentCompletionPageConfiguration')
# deviceComanagementAuthorityConfiguration ('deviceComanagementAuthorityConfiguration')
# deviceEnrollmentNotificationConfiguration ('enrollmentNotificationsConfiguration')
# windowsRestoreDeviceEnrollmentConfiguration ('windowsRestore')
#
# Each logical bucket gets its own thin subtype below. The base only carries
# import/export/replace behavior — it is NOT registered as a policy type itself
# (no _PolicyGroup), so $script:IntuneTypes only contains the concrete buckets.
#
# Server-side filtering by `deviceEnrollmentConfigurationType eq '...'` is value-exact
# and excludes the 'default*' variants, so subtypes filter client-side via CheckPolicy
# on @odata.type. With identical _API/_QueryList across siblings, Get-GraphPolicies
# coalesces them into ONE batch sub-request and fans rows out to the matching subtype.
class DeviceEnrollmentType : IntunePolicyTypeBase
{
# Abstract: only concrete subtypes (EnrollmentStatusPageType, EnrollmentLimitType, ...)
# may be instantiated. Auto-discovery loops in Invoke-IntuneEventAppInitialized and
# the UI extensions consult Test-ClassIsAbstract on each candidate and skip those
# declaring this static marker, so $script:IntuneTypes only contains concrete buckets.
static [bool] $IsAbstract = $true
DeviceEnrollmentType() : Base()
{
([DeviceEnrollmentType]$this).Init()
}
Init()
{
# Everything in this Init is shared across every concrete bucket. Subtypes only
# override _ID, _APITitle, _PolicyName, _Folder, _QueryList, _PlatformName (when
# platform-specific), and CheckPolicy. Subtypes do NOT need to call this Init
# explicitly — the constructor chain already runs it via : Base() → DeviceEnrollmentType()
# body's ([DeviceEnrollmentType]$this).Init().
$this._API = "deviceManagement/deviceEnrollmentConfigurations"
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
$this._SkipRemoveProperties = @('Id')
$this._PropertiesToRemoveForUpdate = @('priority')
$this._Dependencies = @('Applications')
$this._AssignmentsType = "enrollmentConfigurationAssignments"
$this._Icon = "EnrollmentStatusPage"
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._ObjectClass = "DeviceEnrollmentObject"
$this._VerifyObject = $true
# _ExpandAssignmentsList stays at its default ($true) so the list URL appends
# &$expand=assignments. The Intune portal does this on every enrollment-config
# subtype, and Graph accepts it here, so we get assignments inline and skip the
# follow-up /assignments round-trip in Add-GraphPolicyAssignments.
# No _QueryList here — each subtype owns its filter. Combining filters across
# subtypes via OR was unreliable (Graph's batch endpoint dropped most matches).
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
if($PolicyObject.Object.Priority -eq 0)
{
$ret = @{}
$ret.Add("API","$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)")
$ret.Add("Method","PATCH") # Default profile always exists so update them
$ret
}
else
{
Remove-Property $PolicyObject.Object "Id"
}
return $null
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($SourceObject.Object.Priority -eq 0) { return @{ "Import" = $false } }
return $null
}
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
{
if($PolicyObject.Object.Priority -eq 0) { return @{ "Delete" = $false } }
return $null
}
[Hashtable]PreReplaceCommand([IntunePolicyBase]$PolicyObject)
{
if($PolicyObject.Object.Priority -eq 0) { return @{ "Delete" = $false } }
return $null
}
PostReplaceCommand([IntunePolicyBase]$PolicyObject, [IntunePolicyBase]$SourceObject)
{
Set-EnrollmentRestrictionsPriority $PolicyObject $SourceObject
}
}
# Shared Object class for every DeviceEnrollment subtype. PolicyName, Folder and
# Platform routing all flow from the subtype's TYPE class (_PolicyName / _Folder /
# _PlatformName), not per-instance switches on @odata.type:
# * IntunePolicyTypeBase.GetObject sets policyObject._PolicyType = $this (the calling
# subtype) after construction, so $obj.PolicyType.Folder / .PolicyName resolve to
# the right subtype's values.
# * IntunePolicyBase.Platform getter falls back to _PolicyType._PlatformName when the
# instance doesn't set its own — that's how Windows-only buckets (ESP, WHfB,
# CoMgmt, WindowsRestore) report Platform=Windows.
# The constructor is intentionally empty: IntunePolicyBase's : Base($JsonObj) chain
# already runs the Add-ObjectProperty setup. Anything we'd add to a subclass Init here
# is per-subtype concern and lives on the subtype TYPE.
Class DeviceEnrollmentObject : IntunePolicyBase
{
DeviceEnrollmentObject([PSCustomObject]$JsonObj) : Base($JsonObj) { }
DeviceEnrollmentObject() : Base() { }
[String]GetFileName([String]$Path)
{
# Default policies (priority=0) have a localized boilerplate displayName
# ("All users and all devices") that collides across subtypes — both
# DefaultLimit and DefaultPlatformRestrictions land on the same name and
# one overwrites the other. Their id-suffix is unique per subtype
# (DefaultLimit / DefaultPlatformRestrictions / DefaultWindowsHelloForBusiness /
# DefaultWindows10EnrollmentCompletionPageConfiguration), so we use that.
# Non-default policies have id format <randomGuid>_<configTypeSuffix> where
# the suffix is identical for every policy of a given subtype, so we use
# displayName instead.
# We use TWO signals — priority OR a TenantId-prefixed id — because each
# has a failure mode on its own:
# * priority alone: depends on Graph exposing 'priority' on every payload
# and on it staying =0 for default policies (mostly true but not
# guaranteed across endpoints / future schema changes).
# * TenantId prefix alone: requires $this.TenantId to be populated by the
# time GetFileName runs; it isn't always (file-load paths, certain
# bulk-export code paths skip the TenantId-set step).
# Combining them is robust: a row that's a default in EITHER sense uses the
# id-suffix, everything else uses displayName.
$isDefault = ($this.Object.priority -eq 0) -or `
($this.TenantId -and $this.Id -and $this.Id.StartsWith($this.TenantId + "_"))
if($isDefault) {
$parts = $this.Id -split '_', 2
$name = if($parts.Count -ge 2) { $parts[1] } else { $null }
}
else {
$name = $this.Object.displayName
}
if(-not $name) { $name = $this.Id }
# Same id-suffix rule as IntunePolicyBase.GetFileName, which this override
# replaced wholesale and therefore silently dropped: both the user-facing
# AddIDToExportFile setting AND the bulk-export collision flag were ignored
# here, so two non-default enrollment policies of the same subtype sharing a
# displayName still wrote the same file and one overwrote the other - the
# collision was DETECTED and then not acted on.
#
# A default policy takes its name from the id suffix already, which is unique
# per subtype, so the flag will not normally fire for one; the rule is applied
# unconditionally anyway rather than only in the else-branch, so an id-suffix
# name that does somehow collide is still disambiguated.
$forceId = (Get-SettingValue "AddIDToExportFile") -eq $true -or $this._NeedsIdInFilename -eq $true
if($forceId -and $this.Id -and $this.PolicyType.SkipAddIDOnFileName -ne $true -and $name -ne $this.Id) {
$name = ($name + "_" + $this.Id)
}
$fileName = "$((Remove-InvalidFileNameChars $name)).json"
if($Path) { $fileName = [IO.Path]::Combine($Path, $fileName) }
return $fileName
}
}
# Concrete bucket conventions:
# * Constructor delegates to : Base() (= DeviceEnrollmentType) which runs the shared
# Init via the constructor chain. Subtype Init does NOT call the base Init
# explicitly — that would run the base Init twice.
# * Subtype Init only sets bucket-specific fields: _ID, _APITitle, _PolicyName,
# _Folder, _QueryList, optionally _PlatformName, then registers via AddPolicyType.
# * CheckPolicy stays as a defensive client-side filter; @odata.type is the primary
# discriminator with deviceEnrollmentConfigurationType as a fallback.
class EnrollmentStatusPageType : DeviceEnrollmentType
{
EnrollmentStatusPageType() : Base() { ([EnrollmentStatusPageType]$this).Init() }
Init()
{
$this._ID = "EnrollmentStatusPage"
$this._APITitle = "Enrollment Status Page"
$this._PolicyName = "Enrollment Status Page"
$this._Folder = "EnrollmentStatusPage"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Single eq is enough: empirically Graph returns both default and non-default
# ESPs for this filter. (Adding 'defaultWindows10…' as a second clause causes
# a 400 — that enum value is in the schema but rejected by the live filter parser.)
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windows10EnrollmentCompletionPageConfiguration'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windows10EnrollmentCompletionPageConfiguration')
}
}
class EnrollmentRestrictionsPageType : DeviceEnrollmentType
{
EnrollmentRestrictionsPageType() : Base() { ([EnrollmentRestrictionsPageType]$this).Init() }
Init()
{
$this._ID = "EnrollmentRestrictions"
$this._APITitle = "Enrollment Restrictions"
$this._PolicyName = "Device platform restrictions"
$this._Folder = "EnrollmentRestrictions"
# Cross-platform (covers iOS / Android / Windows etc.) — no _PlatformName.
# Single eq clause empirically returns BOTH per-platform Block Android-style
# configs (@odata.type singular) AND the default combined platform restrictions
# (@odata.type plural, id-suffix _DefaultPlatformRestrictions). 'limit' lives
# on EnrollmentLimitType because stacking a second eq clause on the same
# property in batch mode caused Graph's batch endpoint to drop most matches.
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'singlePlatformRestriction'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -in @(
'#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration',
'#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration')) { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -in @(
'singlePlatformRestriction',
'platformRestrictions',
'defaultPlatformRestrictions'))
}
}
class EnrollmentLimitType : DeviceEnrollmentType
{
EnrollmentLimitType() : Base() { ([EnrollmentLimitType]$this).Init() }
Init()
{
$this._ID = "EnrollmentLimit"
$this._HasPlatform = $false
$this._APITitle = "Enrollment Limit"
$this._PolicyName = "Device limit restrictions"
# Same folder as platform restrictions — matches the OLD baseline export where
# EnrollmentRestrictions/ bundled limit + platform restriction policies together.
$this._Folder = "EnrollmentRestrictions"
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'limit'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentLimitConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -in @('limit', 'defaultLimit'))
}
}
class WindowsHelloForBusinessType : DeviceEnrollmentType
{
WindowsHelloForBusinessType() : Base() { ([WindowsHelloForBusinessType]$this).Init() }
Init()
{
$this._ID = "WindowsHelloForBusiness"
$this._APITitle = "Windows Hello for Business"
$this._PolicyName = "Windows Hello for Business"
$this._Folder = "WindowsHelloForBusiness"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windowsHelloForBusiness'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windowsHelloForBusiness')
}
}
class CoManagementSettingsType : DeviceEnrollmentType
{
CoManagementSettingsType() : Base() { ([CoManagementSettingsType]$this).Init() }
Init()
{
$this._ID = "CoManagementSettings"
$this._APITitle = "Co-Management Settings"
$this._PolicyName = "Co-Management Settings"
$this._Folder = "CoManagementSettings"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'deviceComanagementAuthorityConfiguration'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceComanagementAuthorityConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'deviceComanagementAuthorityConfiguration')
}
}
class WindowsRestoreType : DeviceEnrollmentType
{
WindowsRestoreType() : Base() { ([WindowsRestoreType]$this).Init() }
Init()
{
$this._ID = "WindowsRestore"
$this._APITitle = "Windows Restore"
$this._PolicyName = "Windows Restore"
$this._Folder = "WindowsRestore"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'windowsRestore'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'windowsRestore')
}
}
#########################################################################################
#
# Enrollment Notification
#
#########################################################################################
# region Enrollment Notification
# Note: Email and Push notifications are defined in the Notifications class in the Compliance class file.
# This bucket follows the same shape as the other DeviceEnrollment subtypes:
# no _QueryList (so the URL coalesces with siblings in Get-GraphPolicies), client-side
# filter via CheckPolicy on @odata.type, shared DeviceEnrollmentObject.
class EnrollmentNotificationType : DeviceEnrollmentType
{
EnrollmentNotificationType() : Base() { ([EnrollmentNotificationType]$this).Init() }
Init()
{
$this._ID = "EnrollmentNotification"
$this._HasPlatform = $false
$this._APITitle = "Enrollment notifications"
$this._PolicyName = "Enrollment notification"
$this._Folder = "EnrollmentNotifications"
# Cross-platform (email + push notifications target any enrolled device).
$this._QueryList = "?`$filter=deviceEnrollmentConfigurationType eq 'enrollmentNotificationsConfiguration'"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if ($PolicyObject.'@odata.type' -eq '#microsoft.graph.deviceEnrollmentNotificationConfiguration') { return $true }
return ($PolicyObject.deviceEnrollmentConfigurationType -eq 'enrollmentNotificationsConfiguration')
}
# ToDo: Add support for importing, exporting, copying Notifications between environment eg
# notificationTemplates property has a string list of actual notification template policies Email_<GUID of Notification Template>
}
#########################################################################################
#
# Generic functions
#
#########################################################################################
function Set-EnrollmentRestrictionsPriority
{
param($PolicyObject, $SourceObj)
if($PolicyObject.Object.Priority -eq 0) { return }
$api = "$($PolicyObject.PolicyType.API)/$($PolicyObject.Id)/setpriority"
$priority = [PSCustomObject]@{
priority = $SourceObj.Object.Priority
}
$json = $priority | ConvertTo-Json -Depth 20
Write-Log "Update priority for $($PolicyObject.Name) to $($PolicyObject.Object.Priority)"
Invoke-MSGraphAPI -Url $api -HttpMethod "POST" -Content $json -TokenId $PolicyObject._TokenId
}
#########################################################################################
#
# Android Device Owner Enrollment Profiles
#
#########################################################################################
#
# Profile used to enrol corporate-owned Android devices (dedicated devices,
# fully-managed, AOSP, Teams devices) via QR code or token. Listed flat at
# /deviceManagement/androidDeviceOwnerEnrollmentProfiles — not part of the
# deviceEnrollmentConfigurations multi-subtype tree.
# region AndroidDeviceOwnerEnrollmentProfilesType
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AndroidDeviceOwnerEnrollmentProfilesType : IntunePolicyTypeBase
{
AndroidDeviceOwnerEnrollmentProfilesType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._PolicyName = "Android Enterprise — corporate"
$this._ID = "AndroidDeviceOwnerEnrollmentProfiles"
$this._API = "deviceManagement/androidDeviceOwnerEnrollmentProfiles"
# AndroidCOWP icon (Corporate-Owned With Profile) is the closest
# existing match for the Device Owner enrolment surface.
$this._Icon = "AndroidCOWP"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
# Server-generated bits that come back on GET but Graph rejects on
# POST/PATCH. enrolledDeviceCount + token{*} + qrCode* are derived;
# accountId is set from the calling tenant.
$this._PropertiesToRemove = @('accountId','enrolledDeviceCount','enrollmentTokenUsageCount','qrCodeContent','qrCodeImage','tokenCreationDateTime','tokenExpirationDateTime','tokenValue')
$this._PropertiesToRemoveForUpdate = @('accountId','enrolledDeviceCount','enrollmentTokenUsageCount','qrCodeContent','qrCodeImage','tokenCreationDateTime','tokenExpirationDateTime','tokenValue','enrollmentMode','enrollmentTokenType')
# Profile, not policy — no group assignments.
$this._SupportsAssignments = $false
# Graph returns HTTP 400 on `androidDeviceOwnerEnrollmentProfiles?$expand=assignments`,
# even though SupportsAssignments=$false; the list-URL builder still
# appends the expand unless this is explicitly suppressed.
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "AndroidDeviceOwnerEnrollmentProfileObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AndroidDeviceOwnerEnrollmentProfileObject : IntunePolicyBase
{
AndroidDeviceOwnerEnrollmentProfileObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AndroidDeviceOwnerEnrollmentProfileObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AndroidDeviceOwnerEnrollmentProfilesType")
# Language pack uses Platform.androidForWork for AOSP/Android Enterprise
# surfaces; fall back to a literal if the key is missing in en-US.
$this._PlatformName = Get-LanguageString "Platform.androidForWork" -IgnoreMissing
if(-not $this._PlatformName) { $this._PlatformName = "Android Enterprise" }
}
}
#########################################################################################
#
# Android For Work Enrollment Profiles
#
#########################################################################################
#
# Profile used to enrol personal Android devices into a managed Work Profile
# (BYOD). Endpoint at /deviceManagement/androidForWorkEnrollmentProfiles. No
# scope tag support, no assignments — purely token + QR for the end user.
# region AndroidForWorkEnrollmentProfilesType
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AndroidForWorkEnrollmentProfilesType : IntunePolicyTypeBase
{
AndroidForWorkEnrollmentProfilesType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._PolicyName = "Android Enterprise — work profile"
$this._ID = "AndroidForWorkEnrollmentProfiles"
$this._API = "deviceManagement/androidForWorkEnrollmentProfiles"
# AndroidGooglePlay icon — work-profile enrolment is the personal-device
# / Play-store-managed surface, so the GP icon reads better than the
# corporate AndroidCOWP one used for the Device Owner type.
$this._Icon = "AndroidGooglePlay"
$this._Permissions = @("DeviceManagementServiceConfig.ReadWrite.All")
# Schema has no roleScopeTagIds, so disable the scope-tag column /
# detail-view widget for this type. Leaving the default ("roleScopeTagIds")
# would surface an empty UI and a 400 on save.
$this._ScopeTagProperty = $null
# Server-generated fields Graph rejects on POST/PATCH.
$this._PropertiesToRemove = @('accountId','enrolledDeviceCount','qrCodeContent','qrCodeImage','tokenValue','tokenExpirationDateTime')
$this._PropertiesToRemoveForUpdate = @('accountId','enrolledDeviceCount','qrCodeContent','qrCodeImage','tokenValue','tokenExpirationDateTime')
$this._SupportsAssignments = $false
# Graph returns HTTP 400 on `androidForWorkEnrollmentProfiles?$expand=assignments`.
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "AndroidForWorkEnrollmentProfileObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class AndroidForWorkEnrollmentProfileObject : IntunePolicyBase
{
AndroidForWorkEnrollmentProfileObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AndroidForWorkEnrollmentProfileObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AndroidForWorkEnrollmentProfilesType")
$this._PlatformName = Get-LanguageString "Platform.androidForWork" -IgnoreMissing
if(-not $this._PlatformName) { $this._PlatformName = "Android Enterprise" }
}
}
#########################################################################################
#
# Settings Catalog
#
#########################################################################################
# region Settings Catalog
class EnrollmentSettingsCatalogType : SettingsCatalogTypeBase
{
EnrollmentSettingsCatalogType() : Base()
{
([EnrollmentSettingsCatalogType]$this).Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._APITitle = "Enrollment Policies (Settings Catalog)"
$this._ID = "EnrollmentSettingsCatalog"
# Startup default only - Invoke-IntuneSettingsCatalogAuthenticated rebuilds
# this from _FamilyTypes once Graph reports the live template list.
# windowsOsRecoveryPolicies is NOT listed here: it falls to SettingsCatalog's
# catch-all spec, so claiming it would only fetch rows CheckPolicy rejects.
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'enrollmentConfiguration'"
$this._Icon = "EnrollmentStatusPage"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 160
}
}
<#
class AutopilotDevicePreparationSettingsCatalogType : SettingsCatalogTypeBase
{
AutopilotDevicePreparationSettingsCatalogType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "DeviceEnrollmentGroup")
$this._ID = "AutopilotDevicePreparationSettingsCatalog"
$this._APITitle = "Autopilot Device Preparation (Settings Catalog)"
#$this._QueryList = "?`$filter=(technologies has 'enrollment') and (platforms eq 'windows10') and (TemplateReference/templateId eq '80d33118-b7b4-40d8-b15f-81be745e053f_1') and (Templatereference/templateFamily eq 'enrollmentConfiguration')"
$this._QueryList = "?`$filter=(technologies has 'enrollment') and (platforms eq 'windows10') and (Templatereference/templateFamily eq 'enrollmentConfiguration')"
$this._Folder = "SettingsCatalog"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 100
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementConfigurationPolicy") { return $false }
if($PolicyObject.templateReference.templateFamily -and $PolicyObject.templateReference.templateFamily -eq 'enrollmentConfiguration') {
return $true
}
return $false
}
}
#>
#endregion
+378
View File
@@ -0,0 +1,378 @@
#ImportOrder 220
#########################################################################################
#
# Intune Info Group
#
# Read-only objects ported from the original project's "Intune Info" view
# (Extensions/EndpointManagerInfo.psm1). Every type here is Export/View only:
# no import, delete, copy or assignment support. Android Google Play status
# and Tenant Settings are single-object endpoints (_SingleObject; the body IS
# the row) with a synthesized displayName because the API has none.
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class IntuneInfoGroup : IntunePolicyGroupBase
{
IntuneInfoGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "IntuneInfo"
# Templates are this group's substance: Version fills for both template
# kinds, State for templates, VPP tokens and the Google Play binding.
$this._ExtraColumns = @("Version", "State")
$this._Name = "Intune Info"
$this._Icon = "Report"
$this._ShowButtons = @("Export","View")
}
}
#########################################################################################
#
# Baseline Templates - Intent
#
#########################################################################################
class BaselineTemplatesType : IntunePolicyTypeBase
{
BaselineTemplatesType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Baseline Templates - Intent"
$this._ID = "BaselineTemplates"
$this._ExtraColumns = @("Version", "State")
$this._API = "deviceManagement/templates"
$this._Icon = "SecurityBaselines"
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
$this._ObjectClass = "BaselineTemplatesObject"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class BaselineTemplatesObject : IntunePolicyBase
{
BaselineTemplatesObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
BaselineTemplatesObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "BaselineTemplatesType")
# Same vocabulary as the Settings Catalog templates, so one Version / State
# column reads evenly across the Intune Info group.
Add-ObjectProperty $this "Version" { if($this.JsonObject) { $this.JsonObject.versionInfo } }
Add-ObjectProperty $this "State" { if($null -eq $this.JsonObject) { $null } elseif($this.JsonObject.isDeprecated -eq $true) { "Deprecated" } else { "Active" } }
}
}
#########################################################################################
#
# Baseline Templates - Settings Catalog
#
#########################################################################################
class BaselineTemplatesSettingsCatalogType : IntunePolicyTypeBase
{
BaselineTemplatesSettingsCatalogType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Templates - Settings Catalog"
$this._ID = "BaselineTemplatesSettingsCatalog"
$this._ExtraColumns = @("Version", "State")
$this._API = "deviceManagement/configurationPolicyTemplates"
$this._Icon = "SecurityBaselines"
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
$this._ObjectClass = "BaselineTemplatesSettingsCatalogObject"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class BaselineTemplatesSettingsCatalogObject : IntunePolicyBase
{
BaselineTemplatesSettingsCatalogObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
BaselineTemplatesSettingsCatalogObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "BaselineTemplatesSettingsCatalogType")
Add-ObjectProperty $this "Version" { if($this.JsonObject) { $this.JsonObject.displayVersion } }
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.lifecycleState } }
}
}
#########################################################################################
#
# Apple VPP Tokens
#
#########################################################################################
class AppleVPPTokensType : IntunePolicyTypeBase
{
AppleVPPTokensType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Apple VPP Tokens"
$this._ID = "AppleVPPTokens"
$this._ExtraColumns = @("State")
$this._HasPlatform = $false
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceAppManagement/vppTokens"
$this._Icon = "AppleVPPTokens"
$this._Permissions = @("DeviceManagementApps.Read.All")
$this._ObjectClass = "AppleVPPTokensObject"
$this._NameProperty = "appleId"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class AppleVPPTokensObject : IntunePolicyBase
{
AppleVPPTokensObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppleVPPTokensObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppleVPPTokensType")
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.state } }
}
}
#########################################################################################
#
# Apple Enrollment Tokens (DEP / Apple Business Manager)
#
#########################################################################################
class AppleEnrollmentTokensType : IntunePolicyTypeBase
{
AppleEnrollmentTokensType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Apple Enrollment Tokens"
$this._ID = "AppleEnrollmentTokens"
$this._ExtraColumns = @("State")
$this._HasPlatform = $false
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/depOnboardingSettings"
$this._QueryList = "?`$top=100"
$this._Icon = "AppleEnrollmentTokens"
$this._Permissions = @("DeviceManagementServiceConfig.Read.All")
$this._ObjectClass = "AppleEnrollmentTokensObject"
$this._NameProperty = "tokenName"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class AppleEnrollmentTokensObject : IntunePolicyBase
{
AppleEnrollmentTokensObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AppleEnrollmentTokensObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AppleEnrollmentTokensType")
# A DEP token has no state field; whether it has expired is the state that
# matters, and it lines up with the VPP token's Valid / Expired.
Add-ObjectProperty $this "State" {
if($null -eq $this.JsonObject -or -not $this.JsonObject.tokenExpirationDateTime) { return $null }
$exp = [DateTime]::MinValue
if([DateTime]::TryParse([string]$this.JsonObject.tokenExpirationDateTime, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind, [ref]$exp)) {
if($exp -lt (Get-Date)) { "Expired" } else { "Valid" }
}
}
}
}
#########################################################################################
#
# Android Google Play (managed store account status - single object)
#
#########################################################################################
class AndroidGooglePlayType : IntunePolicyTypeBase
{
AndroidGooglePlayType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Android Google Play"
$this._ID = "AndroidGooglePlay"
$this._ExtraColumns = @("State")
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (Managed Google Play is the Android Enterprise
# connection).
$this._PlatformName = Get-LanguageString "Platform.androidEnterprise" -IgnoreMissing
$this._API = "deviceManagement/androidManagedStoreAccountEnterpriseSettings"
$this._Icon = "AndroidGooglePlay"
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
$this._ObjectClass = "AndroidGooglePlayObject"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
$this._SingleObject = $true
$this._HasPageSizeSupport = $false
$this._SkipAddIDOnFileName = $true
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[IntunePolicyBase]GetObject([PSCustomObject]$JsonObj)
{
# The settings object has no displayName - synthesize one so the grid
# row and the export file name aren't blank.
if($JsonObj -and -not $JsonObj.PSObject.Properties['displayName']) {
$JsonObj | Add-Member -MemberType NoteProperty -Name 'displayName' -Value 'Managed Google Play' -Force
}
return ([IntunePolicyTypeBase]$this).GetObject($JsonObj)
}
}
Class AndroidGooglePlayObject : IntunePolicyBase
{
AndroidGooglePlayObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
AndroidGooglePlayObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "AndroidGooglePlayType")
Add-ObjectProperty $this "State" { if($this.JsonObject) { ConvertTo-DisplayWords $this.JsonObject.bindStatus } }
}
}
#########################################################################################
#
# Tenant Settings (Intune service settings - single object)
#
#########################################################################################
class TenantSettingsType : IntunePolicyTypeBase
{
TenantSettingsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "IntuneInfoGroup")
$this._PolicyName = "Tenant Settings"
$this._ID = "TenantSettings"
$this._HasPlatform = $false
$this._API = "deviceManagement/settings"
$this._Icon = "TenantSettings"
$this._Permissions = @("DeviceManagementConfiguration.Read.All")
$this._ObjectClass = "TenantSettingsObject"
$this._ShowButtons = @("Export","View")
$this._SupportsAssignments = $false
$this._ExpandAssignmentsList = $false
$this._SingleObject = $true
$this._HasPageSizeSupport = $false
$this._SkipAddIDOnFileName = $true
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[IntunePolicyBase]GetObject([PSCustomObject]$JsonObj)
{
# deviceManagement/settings has neither id nor displayName - synthesize
# the name so the grid row and the export file name aren't blank.
if($JsonObj -and -not $JsonObj.PSObject.Properties['displayName']) {
$JsonObj | Add-Member -MemberType NoteProperty -Name 'displayName' -Value 'Intune Tenant Settings' -Force
}
return ([IntunePolicyTypeBase]$this).GetObject($JsonObj)
}
}
Class TenantSettingsObject : IntunePolicyBase
{
TenantSettingsObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
TenantSettingsObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "TenantSettingsType")
}
}
@@ -0,0 +1,81 @@
#ImportOrder 220
#########################################################################################
#
# Multi Admin Approval (MAA)
#
# operationApprovalPolicies - the access policies that decide WHICH operations need a
# second administrator's approval, and which Entra groups may approve them.
#
# Exposed read-only on purpose. Creating or editing an access policy is itself an
# MAA-protected "Tenant Configuration" change that needs a second admin to approve, and
# a wrong policy can lock every administrator out of a workload. Export is enabled so
# the configuration can be documented, diffed and migrated by hand; import is not.
#
# The request queue (deviceManagement/operationApprovalRequests - where a 412 from a
# write lands; the approval code is the request id) is deliberately NOT a policy type:
# it is transient state keyed by GUID with nothing to export, and the only useful
# actions on it (approve / reject) would belong to a small tenant-admin tool.
#
# See Internal/MSGraphErrors.ps1 for the 400/403/412 classification that surfaces the
# approval code to the caller.
#
#########################################################################################
#region Operation Approval Policies
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class OperationApprovalPoliciesType : IntunePolicyTypeBase
{
OperationApprovalPoliciesType() : Base() { $this.Init() }
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Multi Admin Approval Policies"
$this._ID = "OperationApprovalPolicies"
$this._HasPlatform = $false
$this._API = "deviceManagement/operationApprovalPolicies"
$this._Permissions = @("DeviceManagementRBAC.Read.All")
# Read-only: see the file header. Editing an access policy is itself gated by
# MAA and can lock admins out of a workload.
$this._ShowButtons = @("View","Export")
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._ObjectClass = "OperationApprovalPolicyObject"
$this._Icon = "TenantSettings"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
# approverGroupIds are Entra group ids, so they are meaningless in another
# tenant without translation. Capture them the same way Conditional Access
# captures its include/exclude groups, so the export carries the sidecars a
# future migration would need.
$ids = @()
foreach($id in @($PolicyObject.JsonObject.approverGroupIds))
{
if([String]::IsNullOrWhiteSpace($id)) { continue }
if($id -in $ids) { continue }
$ids += $id
Add-GraphMigrationObject $id "groups" "Group" ([IO.Path]::GetDirectoryName($PathToFile)) $PolicyObject._TokenId
}
}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class OperationApprovalPolicyObject : IntunePolicyBase
{
OperationApprovalPolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
OperationApprovalPolicyObject() : Base() { $this.Init() }
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "OperationApprovalPoliciesType")
}
}
#endregion
+425
View File
@@ -0,0 +1,425 @@
#ImportOrder 220
#########################################################################################
#
# Script Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class ScriptsGroup : IntunePolicyGroupBase
{
ScriptsGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "ScriptsAndRemediations"
$this._Name = "Scripts and remediations"
$this._Icon = "Scripts"
# Three members share the Policy type "Platform Script"; Script type is the
# language. File name is worth its blanks on the members without a file.
$this._ExtraColumns = @("ScriptType=Script type", "Object.fileName=File name")
}
}
#########################################################################################
#
# Script Base Type
#
#########################################################################################
class ScriptTypeBase : IntunePolicyTypeBase
{
static [bool] $IsAbstract = $true
ScriptTypeBase() : Base()
{
([ScriptTypeBase]$this).Init()
}
Init()
{
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
if($PathToFile -and [IO.File]::Exists($PathToFile) -and $PolicyObject.JsonObject.scriptContent -and (Get-CacheObject "ExportScripts") -eq $true)
{
$fi = [IO.FileInfo]$PathToFile
Write-Log "Export script $($PolicyObject.JsonObject.FileName)"
$fileName = [IO.Path]::Combine($fi.DirectoryName, $PolicyObject.JsonObject.FileName)
try {
[IO.File]::WriteAllBytes($fileName, ([System.Convert]::FromBase64String($PolicyObject.JsonObject.scriptContent)))
}
catch {
Write-LogError "Failed to save script file" $_.Exception
}
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
return $false
}
}
#########################################################################################
#
# PowerShell Script
#
#########################################################################################
# region PowerShell Script
class PowerShellScriptType : ScriptTypeBase
{
PowerShellScriptType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
$this._PolicyName = "Platform Script"
$this._PolicyBaseName = "PowerShell Scripts"
$this._APITitle = "Scripts (PowerShell)"
$this._ID = "PowerShellScripts"
$this._API = "deviceManagement/deviceManagementScripts"
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
$this._AssignmentsType = "deviceManagementScriptAssignments"
$this._ObjectClass = "PowerShellScriptObject"
$this._Icon = "Scripts"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class PowerShellScriptObject : IntunePolicyBase
{
PowerShellScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
PowerShellScriptObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.windows"
Add-ObjectProperty $this "ScriptType" { "PowerShell script" }
$this._PolicyType = (Get-SingletonObject "PowerShellScriptType")
}
}
#endregion
#########################################################################################
#
# Shell Script
#
#########################################################################################
# region PowerShell Script
class ShellScriptType : ScriptTypeBase
{
ShellScriptType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
$this._PolicyName = "Platform Script"
$this._PolicyBaseName = "DeviceShell Scripts"
$this._APITitle = "Scripts (Shell)"
$this._ID = "MacScripts"
$this._API = "deviceManagement/deviceShellScripts"
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
$this._AssignmentsType = "deviceManagementScriptAssignments"
# GET {id}/assignments returns 400 for shell scripts (verified live
# 2026-09-22); {id}?$expand=assignments is the working read path.
$this._AssignmentsViaExpand = $true
$this._ObjectClass = "ShellScriptObject"
$this._Icon = "Scripts"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class ShellScriptObject : IntunePolicyBase
{
ShellScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ShellScriptObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.macOS"
Add-ObjectProperty $this "ScriptType" { "Shell script" }
$this._PolicyType = (Get-SingletonObject "ShellScriptType")
}
}
#endregion
#########################################################################################
#
# Platform Script
#
#########################################################################################
# region Platform Script
class ScriptSettingsCatalogType : SettingsCatalogTypeBase
{
ScriptSettingsCatalogType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
$this._PolicyName = "Platform Script"
$this._APITitle = "Scripts (Linux)"
$this._ID = "DeviceConfigurationScripts"
$this._QueryList = "?`$filter=templateReference/TemplateFamily eq 'deviceConfigurationScripts'"
$this._ObjectClass = "DeviceConfigurationScriptObject"
$this._Icon = "Scripts"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 50
}
}
Class DeviceConfigurationScriptObject : IntunePolicyBase
{
DeviceConfigurationScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DeviceConfigurationScriptObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.linux"
Add-ObjectProperty $this "ScriptType" { "Shell script" }
$this._PolicyType = (Get-SingletonObject "ScriptSettingsCatalogType")
}
}
#endregion
#########################################################################################
#
# Shell Script
#
#########################################################################################
# region PowerShell Script
class MacCustomAttributeType : ScriptTypeBase
{
MacCustomAttributeType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
$this._PolicyName = "Custom Attributes"
$this._PolicyBaseName = "Custom Attributes"
$this._ID = "MacCustomAttributes"
$this._API = "deviceManagement/deviceCustomAttributeShellScripts"
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
$this._AssignmentsType = "deviceManagementScriptAssignments"
# Same 400 on GET {id}/assignments as the shell scripts above.
$this._AssignmentsViaExpand = $true
$this._ObjectClass = "MacCustomAttributeObject"
$this._Icon = "CustomAttributes"
$this._PropertiesToRemoveForUpdate = @('customAttributeName','customAttributeType','displayName')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class MacCustomAttributeObject : IntunePolicyBase
{
MacCustomAttributeObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
MacCustomAttributeObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.macOS"
Add-ObjectProperty $this "ScriptType" { "Shell script" }
$this._PolicyType = (Get-SingletonObject "MacCustomAttributeType")
}
}
#endregion
#########################################################################################
#
# Shell Script
#
#########################################################################################
# region PowerShell Script
class DeviceHealthScriptType : IntunePolicyTypeBase
{
DeviceHealthScriptType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "ScriptsGroup")
$this._PolicyName = "Remediation Script"
$this._APITitle = "Remediation Scripts"
$this._PolicyBaseName = "Remediations"
$this._ID = "DeviceHealthScripts"
$this._API = "deviceManagement/deviceHealthScripts"
$this._QueryList = "?`$filter=isGlobalScript eq false" # Looks like filters are not working for deviceHealthScripts
$this._Permissions = @("DeviceManagementScripts.ReadWrite.All")
$this._ObjectClass = "DeviceHealthScriptObject"
$this._Icon = "Report"
$this._AssignmentsType = "deviceHealthScriptAssignments"
$this._ExpandAssignmentsList = $false
$this._AssignmentPropertiesToKeep = @("target","runSchedule","runRemediationScript")
# deviceHealthScriptType is a read-only GET property - PATCHing it back
# is rejected ("Invalid property name: DeviceHealthScriptType").
$this._PropertiesToRemoveForUpdate = @('version','isGlobalScript','highestAvailableVersion','deviceHealthScriptType')
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([IntunePolicyBase]$PolicyObject)
{
if($PolicyObject.Object.isGlobalScript -eq $true)
{
@{ "Import" = $false }
}
return (@{})
}
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
{
if($PolicyObject.Object.isGlobalScript -eq $true)
{
@{ "Delete" = $false }
}
return (@{})
}
[Hashtable]PreUpdateCommand([IntunePolicyBase]$PolicyObject, [IntunePolicyBase]$SourceObject)
{
if($SourceObject.Object.isGlobalScript -eq $true)
{
# Class methods discard non-return expressions - without the
# explicit return, global scripts were NOT skipped.
return @{ "Update" = $false }
}
return (@{})
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
if($PathToFile -and [IO.File]::Exists($PathToFile) -and $PolicyObject.JsonObject.detectionScriptContent -and (Get-CacheObject "ExportScripts") -eq $true)
{
Write-Log "Export remediation scripts"
$fi = [IO.FileInfo]$PathToFile
try
{
if($PolicyObject.JsonObject.detectionScriptContent) {
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_DetectionScript.ps1")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.detectionScriptContent)))
}
if($PolicyObject.JsonObject.remediationScriptContent) {
[IO.File]::WriteAllBytes(([IO.Path]::Combine($fi.DirectoryName, "$($fi.BaseName)_RemediationScript.ps1")), ([System.Convert]::FromBase64String($PolicyObject.JsonObject.remediationScriptContent)))
}
}
catch
{
Write-LogError "Failed to export remediation scripts" $_.Exception
}
}
}
}
Class DeviceHealthScriptObject : IntunePolicyBase
{
DeviceHealthScriptObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DeviceHealthScriptObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PlatformName = Get-LanguageString "Platform.windows"
Add-ObjectProperty $this "ScriptType" { "Remediation Script" }
$this._PolicyType = (Get-SingletonObject "DeviceHealthScriptType")
}
}
#endregion
#########################################################################################
#
# Script functions
#
#########################################################################################
function Save-IntuneScriptContent
{
param($ScriptPolicy, [string]$FileName)
if(-not $ScriptPolicy) { return }
if($ScriptPolicy.IsFullObject -eq $false) {
[void]$ScriptPolicy.Get()
}
if(-not $ScriptPolicy.JsonObject.scriptContent) { return }
if([string]::IsNullOrWhiteSpace($FileName)) { throw "A destination file path is required." }
Write-Log "Download PowerShell script '$($ScriptPolicy.JsonObject.FileName)' from $($ScriptPolicy.Name)"
# Changed to WriteAllBytes to get rid of BOM characters from Custom Attribute file
[IO.File]::WriteAllBytes($FileName, ([System.Convert]::FromBase64String($ScriptPolicy.JsonObject.scriptContent)))
return $FileName
}
+273
View File
@@ -0,0 +1,273 @@
#ImportOrder 110
#########################################################################################
#
# Settings Catalog
#
#########################################################################################
# region Settings Catalog
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class SettingsCatalogTypeBase : IntunePolicyTypeBase
{
static [bool] $IsAbstract = $true
Hidden [string[]]$_FamilyTypes = @()
SettingsCatalogTypeBase() : Base()
{
([SettingsCatalogTypeBase]$this).Init()
}
Init()
{
$this._NameProperty = "name"
$this._PolicyName = "Settings Catalog"
$this._ID = "SettingsCatalogBase"
$this._API = "deviceManagement/configurationPolicies"
$this._PolicyBaseName = "Settings Catalog"
$this._PropertiesToRemove = @('settingCount')
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._SubTypeColumn = "TemplateFamily=Family"
$this._Expand = "Settings"
$this._Icon = "DeviceConfiguration"
$this._Dependencies = @("ReusableSettings")
$this._ObjectClass = "SettingsCatalogObject"
$this._VerifyObject = $true
$this._PolicyTypeOrder = 200
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
# Settings Catalog updates must PUT the full body (including settings);
# a PATCH carrying the settings payload is rejected by Graph. Same
# contract as the portal and the original project
# (Start-PreUpdateSettingsCatalog).
return @{ "Method" = "PUT" }
}
[Hashtable]GetCompareConfig()
{
return @{
Prop = "settings"
GetKey = { param($s) Get-SettingsCatalogSettingKey $s }
GetValue = { param($s) Get-SettingsCatalogSettingValue $s }
GetCategory = { param($s) Get-SettingsCatalogSettingCategory $s }
}
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.deviceManagementConfigurationPolicy") { return $false }
if($PolicyObject.templateReference.templateFamily -notin $this._FamilyTypes) {
return $false
}
return $true
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
if($PolicyObject.JsonObject.templateReference.templateId) {
# I do not like this at all and it is a lazy but simple implementation...
# It turns out that settingInstanceTemplateId and settingValueTemplateId are case sensitive
# and there is ONE setting with a different casing in the Windows Baseline template.
# The export saves it with lowercase which causes the import to fail.
Write-Log "Get template $($PolicyObject.JsonObject.templateReference.templateId)"
$templateObj = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicyTemplates('$($PolicyObject.JsonObject.templateReference.templateId)')"
if($templateObj.lifecycleState -and $templateObj.lifecycleState -ne "active") {
Write-Log "Template '$($templateObj.displayName)' '$($templateObj.displayVersion)' is in '$($templateObj.lifecycleState)' state. Current state: $($templateObj.lifecycleState). Import might fail." 2
}
#Todo: Should probably check for the latest active version and use that instead of the one in the templateReference
if(-not $script:baseLineTemplate) {
$script:baseLineTemplate = @{}
}
if($script:baseLineTemplate.ContainsKey($PolicyObject.JsonObject.templateReference.templateId)) {
$templateReference = $script:baseLineTemplate[$PolicyObject.JsonObject.templateReference.templateId]
}
else {
Write-Log "Get template settings for '$($templateObj.displayName)' '$($templateObj.displayVersion)' ($($PolicyObject.JsonObject.templateReference.templateId))"
$templateReference = Invoke-MSGraphAPI -Url "/deviceManagement/configurationPolicyTemplates('$($PolicyObject.JsonObject.templateReference.templateId)')/settingTemplates?`$expand=settingDefinitions&top=1000"
$script:baseLineTemplate.Add($PolicyObject.JsonObject.templateReference.templateId, $templateReference)
}
if($templateReference) {
$settingsJson = $PolicyObject.JsonObject.Settings | ConvertTo-Json -Depth 50
$templateIDs, $dummy = Get-DependencyIDs ($templateReference | ConvertTo-Json -Depth 50)
$objectIDs, $dummy = Get-DependencyIDs $settingsJson
$diff = Compare-Object @($templateIDs) @($objectIDs) -CaseSensitive
$updated = $false
foreach($diffItem in ($diff | Where-Object SideIndicator -eq "=>")) {
$templateID = $templateIDs | Where-Object { $_ -eq $diffItem.InputObject }
if($templateID) {
# Found but with different casing
$settingsJson = $settingsJson -replace $diffItem.InputObject, $templateID
$updated = $true
}
}
if($updated) {
$PolicyObject.JsonObject.Settings = @($settingsJson | ConvertFrom-Json -Depth 50)
}
}
}
return $null
}
}
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class SettingsCatalogObject : IntunePolicyBase
{
Hidden [String]$_TemplateFamilyName = $null
SettingsCatalogObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
SettingsCatalogObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$settingCatalogClasses = Get-IntuneSettingsCatalogClasses
$policyType = $settingCatalogClasses | Where-Object { $_._FamilyTypes -contains $this.JsonObject.templateReference.templateFamily }
if($policyType) {
$this._PolicyType = $policyType
}
$this._PolicyName = ?? $this.JsonObject.templateReference.templateDisplayName $this._PolicyType.PolicyBaseType
# Opt into the sub-resource batching contract so Invoke-PolicyHydrate
# fans out the assignments fetch via $batch.
$this._HasSubResourceBatch = $true
Add-ObjectProperty $this "TemplateVersion" { $this.JsonObject.templateReference.templateDisplayVersion }
if($this.JsonObject.templateReference.templateFamily) {
$this._TemplateFamilyName = (?? (Get-EndpointSecurityCategoryName $this.JsonObject.templateReference.templateFamily) $this.JsonObject.templateReference.templateFamily)
}
else {
$this._TemplateFamilyName = $null
}
Add-ObjectProperty $this "TemplateFamily" { $this._TemplateFamilyName }
Add-ObjectProperty $this "Category" { $this._TemplateFamilyName }
}
#Hidden [String] GetName()
#{
# return $this.JsonObject.Name
#}
# Sub-resource contract. The per-id body GET ($expand=assignments,settings)
# returns an empty `assignments` array — known Graph quirk on
# configurationPolicies. We hit the dedicated /assignments endpoint via
# $batch instead so Invoke-PolicyHydrate fans them out in parallel.
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
return @([PSCustomObject]@{
Key = 'assignments'
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
})
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -eq 'assignments') {
# Comma-prefix forces an array reference through the if-expression —
# without it, PowerShell unwraps a single-element @() on assignment
# and ConvertTo-Json then emits a bare object instead of [{...}].
# Lowercase `assignments` matches the Graph wire shape and the
# `assignments@odata.*` metadata properties the body fetch leaves
# alongside. PSObject is case-insensitive on read, so existing
# callers reading `Assignments` keep working.
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
}
return @()
}
}
#endregion
#########################################################################################
#
# Reusable Settings
#
#########################################################################################
# region Reusable Settings
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class ReusableSettingsTypeBase : IntunePolicyTypeBase
{
static [bool] $IsAbstract = $true
ReusableSettingsTypeBase() : Base()
{
([ReusableSettingsTypeBase]$this).Init()
}
Init()
{
$this._PolicyName = "Reusable Settings"
$this._ID = "ReusableSettingsBase"
$this._API = "deviceManagement/reusablePolicySettings"
$this._PolicyBaseName = "Reusable Settings"
$this._PropertiesToRemove = @('Settings','@OData.Type')
$this._Permissions=@("DeviceManagementConfiguration.ReadWrite.All")
$this._ImportOrder = 70
$this._ExpandAssignmentsList = $false
$this._SkipRemoveProperties = @("@OData.Type")
$this._ObjectClass = "ReusableSettingObject"
$this._SupportsAssignments = $false
$this._PolicyTypeOrder = 210
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
return $false
}
}
class ReusableSettingsObjectBase : IntunePolicyBase
{
ReusableSettingsObjectBase([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.InitReusable() }
ReusableSettingsObjectBase() : Base() { $this.InitReusable() }
Hidden InitReusable()
{
# The list endpoint omits settingInstance; hydration fetches it via the
# sub-resource contract (single GET coalesced into the hydrate $batch).
# Owns the API in one place — was previously duplicated in
# Sync-BulkExportReusableSettings (Internal/PolicyHydrateExtras.ps1).
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
if($this.JsonObject.settingInstance) { return @() } # already present
return @([PSCustomObject]@{
Key = 'reusableSettingInstance'
Url = "$($this._PolicyType.API)/$($this.Id)?`$select=settinginstance,displayname,description"
Headers = @{ Accept = 'application/json;odata.metadata=none' }
})
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -eq 'reusableSettingInstance' -and $Body -and $Body.settingInstance) {
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'settingInstance' -Value $Body.settingInstance -Force
}
return @()
}
}
#endregion
+562
View File
@@ -0,0 +1,562 @@
#ImportOrder 210
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class TenantAdminGroup : IntunePolicyGroupBase
{
TenantAdminGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "TenantAdmin"
# Assignment filters live here and are what people come to this group for:
# their kind and platform are worth the blanks on tags, roles and categories.
$this._ExtraColumns = @("FilterType=Filter Type")
$this._ShowPlatformColumn = $true
$this._Name = "Tenant administration"
$this._Icon = "TenantSettings"
}
}
#########################################################################################
#
# Scope Tags
#
#########################################################################################
# region Scope Tags
class ScopeTagsType : IntunePolicyTypeBase
{
ScopeTagsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Scope Tags"
$this._ID = "ScopeTags"
$this._HasPlatform = $false
$this._HasModified = $false
$this._API = "deviceManagement/roleScopeTags"
$this._QueryList = "?`$filter=isBuiltIn%20eq%20false"
$this._Permissions = @("DeviceManagementRBAC.ReadWrite.All")
$this._ImportOrder = 10
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "ScopeTagObject"
$this._Icon = "TenantSettings"
#!!! ToDo: DocumentAll = $true
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
# Hydration already fanned out /assignments via the
# _HasSubResourceBatch contract on ScopeTagObject — skip the
# per-policy round-trip the helper would otherwise make.
if($script:_skipDirectGet -eq $true) { return }
Add-GraphAssignmentsToExportFile $PolicyObject $PathToFile
}
}
Class ScopeTagObject : IntunePolicyBase
{
ScopeTagObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
ScopeTagObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "ScopeTagsType")
# Opt into the sub-resource batching contract so Invoke-PolicyHydrate
# fans out the assignments fetch via $batch.
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
return @([PSCustomObject]@{
Key = 'assignments'
Url = "$($this._PolicyType.API)/$($this.Id)/assignments"
})
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -eq 'assignments') {
# Comma-prefix forces an array reference through the if-expression —
# without it, PowerShell unwraps a single-element @() on assignment
# and ConvertTo-Json then emits a bare object instead of [{...}].
$assignments = if($Body -and $Body.value) { ,@($Body.value) } else { ,@() }
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'assignments' -Value $assignments -Force
}
return @()
}
}
#########################################################################################
#
# Filters
#
#########################################################################################
# region Filters
class FiltersType : IntunePolicyTypeBase
{
FiltersType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Filters"
$this._ID = "AssignmentFilters"
$this._SubTypeColumn = "FilterType=Filter Type" # same header as the group view
$this._API = "deviceManagement/assignmentFilters"
# This endpoint answers HTTP 400 to any $filter (verified 2026-08-27),
# so name searches filter client-side instead.
$this._SupportsNameFilter = $false
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ImportOrder = 15
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._PropertiesToRemoveForUpdate = @('platform')
$this._PropertiesToRemove = @("payloads")
$this._ScopeTagProperty = "roleScopeTags"
$this._ObjectClass = "FilterObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class FilterObject : IntunePolicyBase
{
Hidden [String]$_FilterType = $null
FilterObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
FilterObject() : Base()
{
$this.Init()
}
Hidden Init()
{
if($this.Object.platform -like "*MobileApplicationManagement") {
$this._FilterType = "Managed apps"
}
else {
$this._FilterType = "Managed devices"
}
if($this.Object.platform -like "windows*") {
$platformLng = "windows10"
}
else {
if($this.Object.platform -like "*MobileApplicationManagement") {
$platformLng = $this.Object.platform -replace "MobileApplicationManagement", ""
}
else {
$platformLng = $this.Object.platform
}
}
Add-ObjectProperty $this "FilterType" { $this._FilterType }
$this._PlatformName = $this._PlatformName = Get-LanguageString "Platform.$($platformLng )"
$this._PolicyType = (Get-SingletonObject "FiltersType")
}
}
#########################################################################################
#
# Role Definitions
#
#########################################################################################
# region Role Definitions
class RoleDefinitionType : IntunePolicyTypeBase
{
RoleDefinitionType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Role Definitions"
$this._ID = "RoleDefinitions"
$this._HasPlatform = $false
$this._HasModified = $false
$this._API = "deviceManagement/roleDefinitions"
$this._QueryList = "?`$filter=isBuiltIn%20eq%20false"
$this._Permissions = @("DeviceManagementRBAC.ReadWrite.All")
$this._ImportOrder = 20
$this._ExpandAssignments = $false
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._ObjectClass = "RoleDefinitionObject"
# 'permissions' is the legacy mirror of 'rolePermissions' - PATCHing
# both makes Graph union them, duplicating every action in the list.
$this._PropertiesToRemoveForUpdate = @('isBuiltInRoleDefinition','isBuiltIn','roleAssignments','permissions') ### !!! ToDo: Add support for roleAssignments
#!!! ToDo: DocumentAll = $true
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
if(-not $PathToFile) { return }
if($script:_skipDirectGet -eq $true) { return }
$folder = [IO.Path]::GetDirectoryName($PathToFile)
# roleAssignments were enriched in place by RoleDefinitionObject's
# sub-resource contract during hydration (and already written to the file
# by ExportToFile). Resolve their referenced groups into the migration
# table for cross-tenant import — no re-fetch, no re-save.
foreach($roleAssignment in @($PolicyObject.Object.roleAssignments))
{
# _TokenId, not _TenantId: the parameter is a token id, and no policy object
# has a _TenantId (it is TenantId, without the underscore). The typo passed
# $null, which bound to the parameter default of 0 - "the default token" -
# so a role definition exported from a non-default tenant resolved its group
# references against the wrong directory.
foreach($groupId in @($roleAssignment.resourceScopes)) { Add-GraphMigrationObject $groupId "groups" "Group" $folder $PolicyObject._TokenId }
foreach($groupId in @($roleAssignment.members)) { Add-GraphMigrationObject $groupId "groups" "Group" $folder $PolicyObject._TokenId }
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
Remove-Property $PolicyObject.Object "RoleAssignments"
Remove-Property $PolicyObject.Object "RoleAssignments@odata.context"
return $null
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($PolicyObject.TenantId -eq $SourceObject.TenantId) { return }
$dependencyObjects = Get-GraphDependencySourceObjects $PolicyObject
$loadedScopeTags = $dependencyObjects["ScopeTags"]
if(($SourceObject.Object.RoleAssignments | Measure-Object).Count -gt 0 -and ($loadedScopeTags | Measure-Object).Count -gt 0)
{
# Documentation way did not work so use the same way as the portal
# Should be created with /deviceManagement/roleDefinitions/{roleDefinitionId}/roleAssignments
foreach($roleAssignment in $SourceObject.Object.RoleAssignments)
{
$roleAssignmentObj = New-object PSObject @{
"description" = $roleAssignment.Description
"displayName"= $roleAssignment.DisplayName
"members" = $roleAssignment.members
"resourceScopes" = $roleAssignment.resourceScopes
"roleDefinition@odata.bind" = "https://$(Get-GraphDomain $PolicyObject._TokenId)/beta/deviceManagement/roleDefinitions('$($PolicyObject.Id)')"
"roleScopeTags@odata.bind" = @()
}
foreach($scopeTag in $roleAssignment.roleScopeTags)
{
Get-GraphTranslatedDependencyObject $scopeTag.Id $SourceObject $PolicyObject
$scopeMigObj = $loadedScopeTags | Where-Object OriginalId -eq $scopeTag.Id
if(-not $scopeMigObj.Id) { continue }
$roleAssignmentObj."roleScopeTags@odata.bind" += "https://$(Get-GraphDomain $PolicyObject._TokenId)/beta/deviceManagement/roleScopeTags('$($scopeMigObj.Id)')"
}
# This will update GroupIds
$json = Update-JsonForEnvironment (ConvertTo-Json $roleAssignmentObj -Depth 20) $PolicyObject $PolicyObject._TokenId
Write-Log "Import Role Assignments"
Invoke-MSGraphAPI -Url "deviceManagement/roleAssignments" -Body $json -Method "POST"
}
}
}
}
Class RoleDefinitionObject : IntunePolicyBase
{
# Collects enriched assignments across the (unordered) batch responses so
# FinalizeSubResources can replace the id-only refs in one shot.
Hidden [Hashtable]$_SubResourceState = $null
RoleDefinitionObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
RoleDefinitionObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "RoleDefinitionType")
# Opt into the sub-resource batching contract so Invoke-PolicyHydrate
# fans out the per-assignment fetch via $batch.
$this._HasSubResourceBatch = $true
}
# The roleAssignments $expand returns id-only refs; enrich each with the full
# assignment + its roleScopeTags. The deviceManagement/roleAssignments/<id>
# API lives ONLY here — was previously duplicated in
# Sync-BulkExportRoleAssignmentDetails (Internal/PolicyHydrateExtras.ps1),
# RoleDefinitionType.PostExportCommand, and RoleDefinitionDocHandler.
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach($roleAssignment in @($this.Object.roleAssignments)) {
if(-not $roleAssignment.Id) { continue }
[void]$reqs.Add([PSCustomObject]@{
Key = "roleasn_$($roleAssignment.Id)"
Url = "deviceManagement/roleAssignments/$($roleAssignment.Id)?`$expand=microsoft.graph.deviceAndAppManagementRoleAssignment/roleScopeTags"
})
}
return $reqs.ToArray()
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Key -like 'roleasn_*' -and $Body) {
if($null -eq $this._SubResourceState) { $this._SubResourceState = @{} }
if(-not $this._SubResourceState.ContainsKey('enriched')) {
$this._SubResourceState['enriched'] = [System.Collections.Generic.List[object]]::new()
}
[void]$this._SubResourceState['enriched'].Add($Body)
}
return @()
}
# Replace the id-only refs with the enriched assignments, keeping the
# lowercase `roleAssignments` property the export file, PostExportCommand,
# PostImportCommand, and the doc handler all read (case-insensitively).
[void] FinalizeSubResources()
{
if($null -ne $this._SubResourceState -and $this._SubResourceState.ContainsKey('enriched')) {
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name 'roleAssignments' -Value $this._SubResourceState['enriched'].ToArray() -Force
}
}
}
#########################################################################################
#
# Intune Branding
#
#########################################################################################
# region Intune Branding
class IntuneBrandingType : IntunePolicyTypeBase
{
IntuneBrandingType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Intune Branding"
$this._ObjectClass = "IntuneBrandingObject"
$this._ID = "IntuneBranding"
$this._HasPlatform = $false
$this._HasModified = $false
$this._API = "deviceManagement/intuneBrandingProfiles"
$this._Permissions = @("DeviceManagementApps.ReadWrite.All")
$this._NameProperty = "profileName"
# Name is profileName (see _NameProperty); the flag is a raw JSON property.
$this._ExtraColumns = @("Object.isDefaultProfile=Default")
$this._SkipRemoveProperties = @('Id')
$this._PropertiesToRemoveForUpdate = @('isDefaultProfile','disableClientTelemetry')
$this._Icon = "Branding"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportCommand([PSCustomObject]$PolicyObject)
{
$ret = @{}
if($PolicyObject.JsonObject.isDefaultProfile)
{
$ret.Add("API",($this.API + "/" + $PolicyObject.Id))
$ret.Add("Method","PATCH") # Default profile always exists so update it
foreach($prop in @("profileName","isDefaultProfile","disableClientTelemetry","profileDescription"))
{
Remove-Property $PolicyObject.JsonObject $prop
}
$ret
}
else
{
# Create new Branding profile does not support images data in the json
# Workaround: (as done by the portal)
# Create a new profile with basic info
# Patch the profile with all the info
foreach($prop in ($PolicyObject.JsonObject.PSObject.Properties | Where-Object {$_.Name -notin @("profileName","profileDescription","roleScopeTagIds")})) #"customPrivacyMessage"
{
Remove-Property $PolicyObject.JsonObject $prop.Name
}
}
Remove-Property $PolicyObject.JsonObject "Id"
return $null
}
PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($PolicyObject.JsonObject.isDefaultProfile) { return }
foreach($prop in @("Id","isDefaultProfile","customPrivacyMessage","disableClientTelemetry")) #"isDefaultProfile","disableClientTelemetry"
{
Remove-Property $SourceObject.JsonObject $prop
}
$json = ($SourceObject.JsonObject | ConvertTo-Json -Depth 20)
Invoke-MSGraphAPI -Url "$($this.API)/$($PolicyObject.Id)" -Body $json -Method "PATCH" | Out-Null
}
PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile)
{
$fi = [IO.FileInfo]$PathToFile
foreach($imgType in @("themeColorLogo","lightBackgroundLogo","landingPageCustomizedImage"))
{
if($PolicyObject.JsonObject.$imgType.Value)
{
$fileName = [IO.Path]::Combine($fi.DirectoryName, "$($PolicyObject.Name)_$imgType.jpg")
[IO.File]::WriteAllBytes($fileName, [System.Convert]::FromBase64String($PolicyObject.JsonObject.$imgType.Value))
}
}
}
[Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject)
{
if($PolicyObject.JsonObject.isDefaultProfile -eq $true)
{
return @{ "Delete" = $false }
}
return $null
}
[Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
if($SourceObject.Object.isDefaultProfile)
{
foreach($prop in @("profileName","isDefaultProfile","disableClientTelemetry","profileDescription"))
{
Remove-Property $PolicyObject.JsonObject $prop
}
}
return $null
}
}
Class IntuneBrandingObject : IntunePolicyBase
{
IntuneBrandingObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
IntuneBrandingObject() : Base()
{
$this.Init()
}
Hidden static [String[]]$_ImageProperties = @('themeColorLogo','lightBackgroundLogo','landingPageCustomizedImage')
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "IntuneBrandingType")
# The body endpoint returns image refs without data; hydration fetches
# each image via the sub-resource contract (coalesced into the hydrate
# $batch). Owns the API in one place — was previously duplicated in
# Sync-BulkExportBrandingImages (Internal/PolicyHydrateExtras.ps1).
$this._HasSubResourceBatch = $true
}
[PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase)
{
if($Phase -ne 1) { return @() }
$reqs = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach($imageType in [IntuneBrandingObject]::_ImageProperties) {
[void]$reqs.Add([PSCustomObject]@{
Key = $imageType
Url = "$($this._PolicyType.API)/$($this.Id)/$imageType"
Headers = @{ Accept = 'application/json;odata.metadata=none' }
})
}
return $reqs.ToArray()
}
[PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body)
{
if($Phase -eq 1 -and $Body -and $Body.Value -and $Key -in [IntuneBrandingObject]::_ImageProperties) {
Add-Member -InputObject $this.JsonObject -MemberType NoteProperty -Name $Key -Value $Body -Force
}
return @()
}
}
#########################################################################################
#
# Device Categories
#
#########################################################################################
class DeviceCategoriesType : IntunePolicyTypeBase
{
DeviceCategoriesType() : Base() { $this.Init() }
Init()
{
$this._PolicyGroup = (Get-SingletonObject "TenantAdminGroup")
$this._PolicyName = "Device Categories"
$this._ID = "DeviceCategories"
$this._HasPlatform = $false
$this._HasModified = $false
$this._API = "deviceManagement/deviceCategories"
$this._QueryList = "?`$top=500"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ExpandAssignmentsList = $false
$this._SupportsAssignments = $false
$this._ObjectClass = "DeviceCategoriesObject"
$this._Icon = "TenantSettings"
if($null -ne $this._PolicyGroup) { $this._PolicyGroup.AddPolicyType($this) }
}
}
class DeviceCategoriesObject : IntunePolicyBase
{
DeviceCategoriesObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DeviceCategoriesObject() : Base() { $this.Init() }
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "DeviceCategoriesType")
}
}
#endregion
+132
View File
@@ -0,0 +1,132 @@
#ImportOrder 220
#########################################################################################
#
# Windows 365 Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class Windows365Group : IntunePolicyGroupBase
{
Windows365Group() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "Windows365"
$this._Name = "Windows 365"
$this._Icon = "Devices"
}
}
#########################################################################################
#
# W365 Provisioning Policy
#
#########################################################################################
# region W365 Provisioning Policy
class Win365ProvisioningType : IntunePolicyTypeBase
{
Win365ProvisioningType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "Windows365Group")
$this._APITitle = "W365 Provisioning Policies"
$this._PolicyName = "W365 Provisioning"
$this._ID = "W365ProvisioningPolicies"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (Cloud PC provisioning is Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/virtualEndpoint/provisioningPolicies"
$this._Permissions = @("CloudPC.ReadWrite.All")
$this._Icon = "Devices"
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "Win365ProvisioningObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class Win365ProvisioningObject : IntunePolicyBase
{
Win365ProvisioningObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
Win365ProvisioningObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "Win365ProvisioningType")
}
}
#endregion
#########################################################################################
#
# W365 User Settings
#
#########################################################################################
# region W365 User Settings
class Win365UserSettingsType : IntunePolicyTypeBase
{
Win365UserSettingsType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "Windows365Group")
$this._APITitle = "W365 User Settings"
$this._PolicyName = "W365 User Setting"
$this._ID = "W365UserSettings"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (Cloud PC user settings are Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/virtualEndpoint/userSettings"
$this._Permissions = @("CloudPC.ReadWrite.All")
$this._Icon = "Devices"
$this._ExpandAssignmentsList = $false
$this._ObjectClass = "Win365UserSettingObject"
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class Win365UserSettingObject : IntunePolicyBase
{
Win365UserSettingObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
Win365UserSettingObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "Win365UserSettingsType")
}
}
#endregion
+357
View File
@@ -0,0 +1,357 @@
#ImportOrder 220
#########################################################################################
#
# Windows Update Group
#
#########################################################################################
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class WindowsUpdateGroup : IntunePolicyGroupBase
{
WindowsUpdateGroup() : Base()
{
$this.Init()
}
Init()
{
$this._ID = "WindowsUpdates"
$this._Name = "Windows 10 and later updates"
$this._Icon = "UpdatePolicies"
}
}
#########################################################################################
#
# Update Rings
#
#########################################################################################
# region Update Rings
class WindowsUpdatePolicyType : IntunePolicyTypeBase
{
WindowsUpdatePolicyType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Update rings"
$this._ID = "UpdatePolicies"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing # every member of the group is Windows
$this._API = "deviceManagement/deviceConfigurations"
$this._QueryList = "?`$filter=isof(%27microsoft.graph.windowsUpdateForBusinessConfiguration%27)"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "WindowsUpdatePolicyObject"
$this._PropertiesToRemove = @('version','qualityUpdatesPauseStartDate','featureUpdatesPauseStartDate','qualityUpdatesWillBeRolledBack','featureUpdatesWillBeRolledBack')
$this._Icon = "UpdatePolicies"
$this._ExpandAssignmentsList = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 90
}
[Boolean]CheckPolicy([PSCustomObject]$PolicyObject)
{
if($PolicyObject.'@odata.type' -ne "#microsoft.graph.windowsUpdateForBusinessConfiguration") { return $false }
return $true
}
}
Class WindowsUpdatePolicyObject : IntunePolicyBase
{
WindowsUpdatePolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
WindowsUpdatePolicyObject() : Base()
{
$this.Init()
}
Hidden Init()
{
#$this._PlatformName = Get-LanguageString "Platform.windows"
#Add-ObjectProperty $this "ScriptType" { "PowerShell script" }
$this._PolicyType = (Get-SingletonObject "WindowsUpdatePolicyType")
}
}
#########################################################################################
#
# Feature Updates
#
#########################################################################################
# region Feature Updates
class FeatureUpdateType : IntunePolicyTypeBase
{
FeatureUpdateType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Feature updates"
$this._ID = "FeatureUpdates"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (windowsFeatureUpdateProfiles is Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/windowsFeatureUpdateProfiles"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "FeatureUpdateObject"
$this._Dependencies = @('Applications')
# Read-only/derived properties the update PATCH must not send back.
$this._PropertiesToRemoveForUpdate = @('deployableContentDisplayName','endOfSupportDate')
$this._TopItems = 0
# Graph returns HTTP 400 on `windowsFeatureUpdateProfiles?$expand=assignments`;
# assignments are loaded via Add-GraphPolicyAssignments after listing.
$this._ExpandAssignmentsList = $false
# The endpoint caps `$top` at 200; the bulk-export default of 1000 produces
# `400: The limit of '200' for Top query has been exceeded`. Disabling page-
# size for this type drops $top entirely — Graph applies its own default.
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
[Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject)
{
return (@{ "API" = "$($this.API)/$($PolicyObject.Id)/microsoft.graph.managedDeviceMobileAppConfiguration/assign" })
}
}
Class FeatureUpdateObject : IntunePolicyBase
{
FeatureUpdateObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
FeatureUpdateObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "FeatureUpdateType")
}
}
#########################################################################################
#
# Quality Updates
#
#########################################################################################
# region Quality Update Profiles
class QualityUpdateProfileType : IntunePolicyTypeBase
{
QualityUpdateProfileType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Quality updates (Profile)"
$this._ID = "QualityUpdates"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (windowsQualityUpdateProfiles is Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/windowsQualityUpdateProfiles"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "QualityUpdateProfileObject"
$this._PropertiesToRemoveForUpdate = @('releaseDateDisplayName','deployableContentDisplayName')
$this._TopItems = 0
$this._Icon = "UpdatePolicies"
# Graph returns HTTP 400 on `windowsQualityUpdateProfiles?$expand=assignments`.
$this._ExpandAssignmentsList = $false
# Endpoint caps `$top` at 200; bulk-export default of 1000 returns 400.
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class QualityUpdateProfileObject : IntunePolicyBase
{
QualityUpdateProfileObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
QualityUpdateProfileObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "QualityUpdateProfileType")
}
}
# region Quality Update Policies
class QualityUpdatePolicyType : IntunePolicyTypeBase
{
QualityUpdatePolicyType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Quality updates (Policy)"
$this._ID = "QualityUpdatePolicies"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (windowsQualityUpdatePolicies is Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/windowsQualityUpdatePolicies"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "QualityUpdatePolicyObject"
$this._TopItems = 0
$this._Icon = "UpdatePolicies"
# Graph returns HTTP 400 on `windowsQualityUpdatePolicies?$expand=assignments`.
$this._ExpandAssignmentsList = $false
# Endpoint caps `$top` at 200; bulk-export default of 1000 returns 400.
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class QualityUpdatePolicyObject : IntunePolicyBase
{
QualityUpdatePolicyObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
QualityUpdatePolicyObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "QualityUpdatePolicyType")
}
}
#########################################################################################
#
# Driver Updates
#
#########################################################################################
# region Driver Updates
class DriverUpdateType : IntunePolicyTypeBase
{
DriverUpdateType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Driver updates"
$this._ID = "DriverUpdateProfiles"
# Platform default: the endpoint serves exactly one platform and the
# objects carry no platforms/platformType field, so the column would
# otherwise be blank (windowsDriverUpdateProfiles is Windows-only).
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
# Endpoint rejects a name $filter (verified live 2026-08-27) - searches filter client-side.
$this._SupportsNameFilter = $false
$this._API = "deviceManagement/windowsDriverUpdateProfiles"
$this._Permissions = @("DeviceManagementConfiguration.ReadWrite.All")
$this._ObjectClass = "DriverUpdateObject"
$this._PropertiesToRemoveForUpdate = @('releaseDateDisplayName','deployableContentDisplayName')
$this._TopItems = 0
$this._Icon = "UpdatePolicies"
# Graph returns HTTP 400 on `windowsDriverUpdateProfiles?$expand=assignments`.
$this._ExpandAssignmentsList = $false
# Endpoint caps `$top` at 200; bulk-export default of 1000 returns 400.
$this._HasPageSizeSupport = $false
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
}
}
Class DriverUpdateObject : IntunePolicyBase
{
DriverUpdateObject([PSCustomObject]$JsonObj) : Base($JsonObj) { $this.Init() }
DriverUpdateObject() : Base()
{
$this.Init()
}
Hidden Init()
{
$this._PolicyType = (Get-SingletonObject "DriverUpdateType")
}
}
#region Maintenance windows (Settings Catalog)
# Windows Update maintenance windows are a Settings Catalog template family
# (maintenanceWindows, backed by the Update/MaintenanceWindow* CSP, Windows 11
# 24H2 + KB5077181). Without this class they fall into SettingsCatalogType's
# catch-all and list under Configuration; every other family is routed to its
# domain group (enrollmentConfiguration -> Device enrollment, endpointSecurity*
# -> Endpoint Security, deviceConfigurationScripts -> Scripts), so this one
# belongs here.
#
# _QueryList is the pre-login default. Invoke-IntuneSettingsCatalogAuthenticated
# rebuilds it from the tenant's live template list and fills _FamilyTypes, which
# is what CheckPolicy matches on; if the tenant has no such template the default
# filter simply returns nothing. SettingsCatalogObject resolves its PolicyType by
# family, so no dedicated object class is needed.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("TypeNotFound","", Justification = "")]
class WindowsUpdateSettingsCatalogType : SettingsCatalogTypeBase
{
WindowsUpdateSettingsCatalogType() : Base()
{
$this.Init()
}
Init()
{
$this._PolicyGroup = (Get-SingletonObject "WindowsUpdateGroup")
$this._PolicyName = "Maintenance window"
$this._APITitle = "Maintenance windows"
$this._ID = "WindowsUpdateSettingsCatalog"
$this._QueryList = "?`$filter=templateReference/templateFamily eq 'maintenanceWindows'"
$this._Icon = "UpdatePolicies"
$this._PlatformName = Get-LanguageString "Platform.windows" -IgnoreMissing
if($null -ne $this._PolicyGroup) {
$this._PolicyGroup.AddPolicyType($this)
}
$this._PolicyTypeOrder = 60
}
}
#endregion