mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
IntuneManagement 4.0.0-beta1
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[]]@()
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user