enum APIAccess { Full Limited None } enum OMetadata { Full Minimal None Skip } # A group of APIs eg Device Configurations has Settings Catalog, Templates etc. class IntunePolicyGroupBase { Hidden [string]$_ID = "" Hidden [string]$_Name = "" Hidden [Object]$_Icon = $null Hidden [IntunePolicyTypeBase[]]$_PolicyTypes = @() # Permission state for the left-nav colour, stamped by # Update-IntuneAccessLevels (Internal/AccessLevel.ps1). Full renders # normally, Limited orange, None red. Groups aggregate their member types: # None only when nothing in the group is usable. AccessInfo is the tooltip # breakdown. Both stay at their defaults when there is no token to diff # against, so an unreadable token shows no colour rather than all red. [APIAccess]$AccessType = [APIAccess]::Full [string]$AccessInfo = "" Hidden [Object]$IconImage = $null # Column list for the group view. $null = derive from the member types # (Get-IntuneDefaultColumns, Internal/IntuneViewColumns.ps1); set only to # hard-override the derivation. Hidden [string[]]$_ViewProperties = $null # Columns the whole group can fill, appended after Policy type - e.g. # "ScriptType=Script type". Keep to values every member type supplies. Hidden [string[]]$_ExtraColumns = @() # $false hides the Policy type column, $true forces it; $null = show it when # the group has more than one member type. Hidden [object]$_ShowPolicyTypeColumn = $null # $false hides Platform, $true forces it even when most members cannot fill it # (Tenant administration: assignment filters are worth the blanks); $null = the # half-the-members rule. Hidden [object]$_ShowPlatformColumn = $null # $null = all buttons / all bulk operations. Set to e.g. @("Export","View") # for read-only groups (UI button gating + bulk-form type lists both check it). Hidden [object]$_ShowButtons = $null IntunePolicyGroupBase() { if($this.GetType().Name -eq "IntunePolicyGroupBase") { 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 ([IntunePolicyGroupBase]$this).Init() } # Hidden Functions Hidden Init() { Add-ObjectProperty $this "ID" { $this._ID } Add-ObjectProperty $this "Title" { $this._Name } Add-ObjectProperty $this "Icon" { Get-StringOrDefault $this._Icon $this._ID } #Add-ObjectProperty $this "IconImage" { return $this._IconImage } Add-ObjectProperty $this "PolicyTypes" { $this._PolicyTypes } Add-ObjectProperty $this "ViewProperties" { ?? $this._ViewProperties (Get-IntuneDefaultColumns $this) } Add-ObjectProperty $this "ShowButtons" { $this._ShowButtons } } # Public Functions [void]AddPolicyType([IntunePolicyTypeBase]$PolicyType) { if(-not $PolicyType) { return } if($null -eq ($this._PolicyTypes | Where-Object ID -eq $PolicyType.ID)) { $this._PolicyTypes += $PolicyType } else { Write-Log "Cannot add PolicyType to PolicyGroup. $($PolicyType.ID) ($($PolicyType.Title)) alredy added" 2 } } } # Represents an API class IntunePolicyTypeBase { Hidden [IntunePolicyGroupBase]$_PolicyGroup = $null Hidden [String]$_Name = $null # See IntunePolicyGroupBase.AccessType. For a type this is derived straight # from _Permissions vs the token's granted scopes/roles. [APIAccess]$AccessType = [APIAccess]::Full [string]$AccessInfo = "" Hidden [String]$_ID = $null Hidden [String]$_APITitle = $null Hidden [String]$_APIVersion = $null Hidden [String]$_API = $null Hidden [String]$_APIPOST = $null Hidden [String]$_APIPUT = $null Hidden [String]$_APIDELETE = $null Hidden [String]$_PolicyName = $null Hidden [String]$_PolicyBaseName = $null Hidden [String[]]$_PropertiesToRemove = $null Hidden [String[]]$_PropertiesToRemoveForUpdate = $null # Properties the PROPERTY compare should not match on for this type - # server-maintained state that legitimately differs between two # identically-configured objects (rendered greyed-out, like the global # isAssigned skip). Membership-style props skipped here should still be # covered semantically by the type's documentation compare. Hidden [String[]]$_ComparePropertiesToSkip = @() Hidden [String[]]$_SkipRemoveProperties = @() Hidden [String[]]$_Permissions = $null # Intune RBAC resource category (the middle of Microsoft.Intune__) # used by the access marking's Layer 2 (Internal/EffectivePermissions.ps1). # $null = derive from _API; set only where the derivation is wrong. Hidden [String]$_ResourceCategory = $null # Column list for this type's view. $null = derive (Get-IntuneDefaultColumns, # Internal/IntuneViewColumns.ps1) from the flags below; set only to # hard-override the derivation. Hidden [string[]]$_ViewProperties = $null # Rows of this type resolve a Platform, per row or via _PlatformName. Hidden [bool]$_HasPlatform = $true # The endpoint returns lastModifiedDateTime / modifiedDateTime. Hidden [bool]$_HasModified = $true # Column that tells rows of this ONE type apart - "PolicyName=Type" where the # object class sets a per-row name, "ApplicationType=Type" for apps. It takes # the place of the generic Policy type column, which would repeat itself. Hidden [string]$_SubTypeColumn = $null # Type-specific additions, e.g. "ApplicationTypeGroup=App type". Hidden [string[]]$_ExtraColumns = @() Hidden [String]$_Expand = $null Hidden [String]$_Icon = $null Hidden [Object]$IconImage = $null Hidden [String[]]$_Dependencies = $null Hidden [String]$_QueryList = $null Hidden [Boolean]$_QuerySearch = $false Hidden [Boolean]$_NavigationProperties = $false Hidden [Boolean]$_ExpandAssignments = $true Hidden [Boolean]$_ExpandAssignmentsList = $true Hidden [Boolean]$_SupportsAssignments = $true Hidden [String]$_AssignmentsType = "assignments" # Action segment used when writing assignments ({API}/{id}/). # Most types use the canonical "assign" action; policySets have no # /assign segment - their update action takes a full `assignments` # replacement list with the same semantics. Hidden [String]$_AssignAction = "assign" # When $true, per-policy assignments are fetched via # {API}/{id}?$expand=assignments instead of the {API}/{id}/assignments # navigation URL (policySets: the navigation GET returns 400, only the # expand variant works - same family as _ExpandAssignmentsList). Hidden [Boolean]$_AssignmentsViaExpand = $false # Optional override consulted by the Bulk Assignments tool. When the # default Get-BulkAssignmentObjectType heuristic (AssignmentsType / Id # lookup) can't derive a Graph @odata.type for this PolicyType's # assignment entries, set this field to the explicit # "#microsoft.graph.XAssignment" string. The lookup logs a warning when # both this override and the heuristic return null — that's the signal # a new type needs to set it. Hidden [String]$_AssignmentObjectType = $null Hidden [String[]]$_AssignmentPropertiesToKeep = $null Hidden [String[]]$_AssignmentTargetPropertiesToKeep = $null Hidden [Uint32]$_ImportOrder = 1000 Hidden [OMetadata]$_ODataMetadata = [OMetadata]::Full Hidden [Boolean]$_SkipAddIDOnFileName = $false Hidden [Boolean]$_ScopeTagsReturnedInList = $true Hidden [String]$_ScopeTagProperty = "roleScopeTagIds" Hidden [String]$_ObjectClass = $null Hidden [String]$_CopyDefaultName = $null Hidden [Boolean]$_HasDescription = $true Hidden [Boolean]$_RequiresFullObject = $true Hidden [Boolean]$_VerifyObject = $false # True when this type's CheckPolicy authoritatively matches/rejects a *file* object # by @odata.type (a complete matcher), rather than relying on the base @odata.id # matcher which is blind to files. When true, a CheckPolicy=$false is a real # rejection, so the single-narrowed-candidate folder-trust fallback in # Get-PoliciesTypeFromObject is skipped. Leave $false for types whose CheckPolicy # cannot reliably identify their own file objects (the base @odata.id matcher, or # ApplicationType's assignments-context heuristic). Hidden [Boolean]$_StrictODataTypeCheck = $false Hidden [String]$_NameProperty = "displayName" # Whether this type's endpoint honours a server-side name filter # (contains/tolower on _NameProperty). Verified live for most Intune # endpoints; set to $false on the ones that answer HTTP 400/500 so the # search filters client-side instead. See GetNameFilterClause. Hidden [Boolean]$_SupportsNameFilter = $true Hidden [String]$_IdProperty = "id" Hidden [String]$_Folder = $null # Optional default platform name for this policy type. When set, the per-row # IntunePolicyBase.Platform getter falls back to this value if the row didn't # set its own _PlatformName. Used by DeviceEnrollment subtypes (ESP, WHfB, # Co-Mgmt, Windows Restore) so the platform doesn't have to be derived from # @odata.type on every Object instance. Hidden [String]$_PlatformName = $null Hidden [String[]]$_PolicyFileAttributes = @() Hidden [Uint32]$_PolicyTypeOrder = 100 # $null = all buttons. Set to e.g. @("Export","View") for read-only types # (the Intune Info group). Gates the main-view buttons and bulk-form lists. Hidden [object]$_ShowButtons = $null # Set when the API returns ONE object instead of a value collection (e.g. # androidManagedStoreAccountEnterpriseSettings, deviceManagement/settings). # Get-GraphPolicies wraps the body as a single row. Combine with # _HasPageSizeSupport = $false so no $top is appended to the list URL. Hidden [Boolean]$_SingleObject = $false Hidden [Uint32]$_TopItems = 1000 Hidden [Boolean]$_HasPageSizeSupport = $true IntunePolicyTypeBase() { if($this.GetType().Name -eq "IntunePolicyTypeBase") { 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 ([IntunePolicyTypeBase]$this).Init() } Static [PSCustomObject]Get([IntunePolicyBase]$GraphObject) { return $GraphObject.Object } [String]GetListURL() { return ([IntunePolicyTypeBase]$this).GetListURL($null) } [String]GetListURL([string]$NameFilter) { $params = @() $stringURI = $this._API if($this._QueryList) { $stringURI += $this._QueryList } # Server-side name search. _QueryList already carries a $filter on some # types (the Settings Catalog siblings filter by templateFamily), and a # URL may only have ONE $filter - so merge into the existing clause with # 'and' rather than appending a second parameter. The existing clause is # parenthesised because those are 'or' chains and 'and' binds tighter. $nameClause = ([IntunePolicyTypeBase]$this).GetNameFilterClause($NameFilter) if($nameClause) { $existingMatch = if($this._QueryList) { [Regex]::Match($this._QueryList, '\$filter=([^&]+)') } else { $null } if($existingMatch -and $existingMatch.Success) { # Splice by index - no regex replacement, so quotes and # parentheses in either clause are safe. $merged = "`$filter=($($existingMatch.Groups[1].Value)) and $nameClause" $stringURI = $this._API + $this._QueryList.Substring(0, $existingMatch.Index) + $merged + $this._QueryList.Substring($existingMatch.Index + $existingMatch.Length) } else { $params += "`$filter=$nameClause" } } if((Get-SettingValue "ExpandAssignments") -eq $true -and $this.ExpandAssignmentsList -ne $false) { # Expand assignments so they can be used in custom columns if(-not $this._QueryList -or ($this._QueryList.IndexOf('$expand=',[System.StringComparison]::InvariantCultureIgnoreCase)) -eq -1) { $params += "`$expand=assignments" } } # ToDo: Add PageSize Setting $pageSizeStr = Get-SettingValue "GraphPageSize" $this._TopItems try { $pageSize = [uint32]$pageSizeStr } catch { $pageSize = 20 } if($this.HasPageSizeSupport -eq $false) { # Do nothing... } elseif($pageSize -gt 0 -and (-not $this._QueryList -or ($this._QueryList.IndexOf('$top=',[System.StringComparison]::InvariantCultureIgnoreCase) -eq -1))) { $params += "`$top=$pageSize" } $url = $stringURI if($params.Count -gt 0) { $url += (?: ([String]::IsNullOrEmpty($this._QueryList)) "?" "&") $url += ($params -join "&") } # NOTE: %OrganizationId% is intentionally NOT substituted here. It used to be # replaced with $script:OrganizationId (the *default* tenant), which broke # cross-tenant lookups when the caller supplied -TokenId pointing at a different # token. Substitution now happens in Invoke-MSGraphAPI using the call's TokenId. return $url } # Base URL used when fetching a single policy's assignments ({base}/{id}/assignments # or {base}/{id}?$expand=assignments). Defaults to the list API. Types whose list # collection is polymorphic must override this: $expand and the assignments # navigation both resolve against the collection's *declared* type, so a base type # that doesn't declare `assignments` returns 400 for either form (App protection - # managedAppPolicies is Collection(managedAppPolicy), which has no navigation # properties at all). Return $null to skip the fetch for an object that cannot # carry assignments. [String]GetAssignmentsBaseURL([PSCustomObject]$PolicyObject) { return $this._API } [PSCustomObject]GetListBatchObject() { return ([IntunePolicyTypeBase]$this).GetListBatchObject($null) } [PSCustomObject]GetListBatchObject([string]$NameFilter) { return [PSCustomObject]@{ id = $this.Id method = "GET" url = ([IntunePolicyTypeBase]$this).GetListURL($NameFilter).TrimStart('/') headers = @{"Accept"="application/json;odata.metadata=$($this._ODataMetadata)"} } } # Server-side name search. Returns the OData filter clause for $NameFilter, # or an empty string when this type cannot be searched that way. # # contains(tolower(prop),'term') rather than startswith(prop,'term'): # verified live 2026-08-27 that Graph's startswith is case-SENSITIVE on # nearly every one of these endpoints (a search for "test" found nothing # named "[Testing] ..."), while the tolower/contains form is accepted and # returns exactly what a client-side substring match would. It also keeps # the substring semantics the picker had before it searched server-side. # # Types whose endpoint rejects any $filter set _SupportsNameFilter = $false # and are filtered client-side by Get-GraphPolicies instead. [String]GetNameFilterClause([string]$NameFilter) { if([String]::IsNullOrWhiteSpace($NameFilter)) { return "" } if($this._SupportsNameFilter -ne $true) { return "" } # Single-object endpoints return the object itself, not a collection - # there is nothing to filter and Graph rejects the attempt. if($this._SingleObject -eq $true) { return "" } $prop = Get-StringOrDefault $this._NameProperty "displayName" # Escaping (quote doubling, percent-encoding, PS5.1/PS7 normalisation) is # ConvertTo-ODataStringLiteral's job - one rule for every literal. $literal = ConvertTo-ODataStringLiteral $NameFilter.ToLowerInvariant() return "contains(tolower($prop),$literal)" } # Hidden Functions Hidden Init() { Add-ObjectProperty $this "ID" { $this._ID } Add-ObjectProperty $this "PolicyGroup" { $this._PolicyGroup } Add-ObjectProperty $this "APIVersion" { Get-StringOrDefault $this._APIVersion "Beta" } Add-ObjectProperty $this "API" { $this._API } Add-ObjectProperty $this "APIPOST" { ?? $this._APIPOST $this._API } Add-ObjectProperty $this "APIPUT" { ?? $this._APIPUT $this._API } Add-ObjectProperty $this "APIDELETE" { ?? $this._APIDELETE $this._API } Add-ObjectProperty $this "PolicyName" { $this._PolicyName } Add-ObjectProperty $this "PolicyBaseName" { ?? $this._PolicyBaseName $this._PolicyName } Add-ObjectProperty $this "PropertiesToRemove" { $this._PropertiesToRemove } Add-ObjectProperty $this "PropertiesToRemoveForUpdate" { $this._PropertiesToRemoveForUpdate } Add-ObjectProperty $this "ComparePropertiesToSkip" { $this._ComparePropertiesToSkip } Add-ObjectProperty $this "SkipRemoveProperties" { $this._SkipRemoveProperties } Add-ObjectProperty $this "Permissions" { $this._Permissions } Add-ObjectProperty $this "ViewProperties" { ?? $this._ViewProperties (Get-IntuneDefaultColumns $this) } Add-ObjectProperty $this "Expand" { $this._Expand } Add-ObjectProperty $this "Dependencies" { $this._Dependencies } Add-ObjectProperty $this "Icon" { Get-StringOrDefault $this._Icon $this._ID } #Add-ObjectProperty $this "IconImage" { $this._IconImage } Add-ObjectProperty $this "QueryList" { $this._QueryList } Add-ObjectProperty $this "QuerySearch" { Get-BoolOrDefault $this._QuerySearch $false } Add-ObjectProperty $this "ExpandAssignments" { $this._ExpandAssignments } Add-ObjectProperty $this "ExpandAssignmentsList" { $this._ExpandAssignmentsList } Add-ObjectProperty $this "SupportsAssignments" { $this._SupportsAssignments } Add-ObjectProperty $this "ScopeTagsReturnedInList" { $this._ScopeTagsReturnedInList } Add-ObjectProperty $this "ImportOrder" { $this._ImportOrder } Add-ObjectProperty $this "ODataMetadata" { $this._ODataMetadata } Add-ObjectProperty $this "AssignmentsType" { $this._AssignmentsType } Add-ObjectProperty $this "AssignAction" { ?? $this._AssignAction "assign" } Add-ObjectProperty $this "AssignmentsViaExpand" { Get-BoolOrDefault $this._AssignmentsViaExpand $false } Add-ObjectProperty $this "AssignmentObjectType" { $this._AssignmentObjectType } Add-ObjectProperty $this "AssignmentPropertiesToKeep" { $this._AssignmentPropertiesToKeep } Add-ObjectProperty $this "AssignmentTargetPropertiesToKeep" { $this._AssignmentTargetPropertiesToKeep } Add-ObjectProperty $this "SkipAddIDOnFileName" { Get-BoolOrDefault $this._SkipAddIDOnFileName $false } Add-ObjectProperty $this "ScopeTagProperty" { Get-StringOrDefault $this._ScopeTagProperty } Add-ObjectProperty $this "NavigationProperties" { $this._NavigationProperties } Add-ObjectProperty $this "HasDescription" { $this._HasDescription } Add-ObjectProperty $this "CopyDefaultName" { $this._CopyDefaultName } Add-ObjectProperty $this "NameProperty" { $this._NameProperty } Add-ObjectProperty $this "SupportsNameFilter" { $this._SupportsNameFilter } Add-ObjectProperty $this "IDProperty" { $this._IDProperty } Add-ObjectProperty $this "VerifyObject" { $this._VerifyObject } Add-ObjectProperty $this "StrictODataTypeCheck" { $this._StrictODataTypeCheck } Add-ObjectProperty $this "Folder" { ?? $this._Folder $this._ID } Add-ObjectProperty $this "Title" { ?? $this._APITitle $this._PolicyName } Add-ObjectProperty $this "HasPageSizeSupport" { $this._HasPageSizeSupport } Add-ObjectProperty $this "ShowButtons" { $this._ShowButtons } Add-ObjectProperty $this "SingleObject" { Get-BoolOrDefault $this._SingleObject $false } } [IntunePolicyBase]GetObject([PSCustomObject]$JsonObj) { if($this._ObjectClass) { $policyObject = (New-Object -TypeName $this._ObjectClass -ArgumentList $JsonObj) if($this.VerifyObject -and $this.CheckPolicy($JsonObj) -eq $false) { $policyObject = $null } # When several PolicyType siblings share the same _ObjectClass (e.g. all # deviceEnrollmentConfigurations subtypes wrap rows in DeviceEnrollmentObject), # the object's Init() picks a single hard-coded singleton — usually the base # class. Override with the *calling* subtype so the policy's PolicyType.Folder # and other type-keyed lookups resolve to the bucket that actually claimed it. if($null -ne $policyObject) { $policyObject._PolicyType = $this $this.LogResolvedPolicy($JsonObj) } return $policyObject } else { Write-Log "Object class is missing for $($this.Title) $($JsonObj."$($this.NameProperty)")" } return $null } [IntunePolicyBase]GetObject([String]$Json) { $jsonObj = (ConvertFrom-Json $Json) return ($this.GetObject($jsonObj)) } [IntunePolicyBase]GetObject([IO.FileInfo]$File) { try { $tmpObject = (New-Object -TypeName $this._ObjectClass -ArgumentList $File) if($this.VerifyObject -and $this.CheckPolicy($tmpObject.JsonObject) -eq $false) { $tmpObject = $null } if($null -ne $tmpObject) { $tmpObject._PolicyType = $this $this.LogResolvedPolicy($tmpObject.JsonObject) } return $tmpObject } catch {} return $null } AddExportProperties([IntuneManagerExportSettings]$ExportSettings) { } AddImportProperties([IntuneManagerImportSettings]$ImportSettings) { } [Boolean]CheckPolicy([PSCustomObject]$PolicyObject) { if($PolicyObject.'@odata.id') { if($PolicyObject.'@odata.id'.StartsWith($this._API.TrimStart('/'))) { return $true } } return $false } # Uniform "found policy" breadcrumb, logged once per object at the point a # type claims it - both the list-load path (Get-GraphPolicies) and the # file-load path (Get-GraphPolicyFromFile) route every resolved object # through GetObject, so this is the single chokepoint. It replaces the # ad-hoc Write-Log lines that a handful of CheckPolicy overrides used to # carry: those fired only for the subset of types that both overrode # CheckPolicy AND happened to include the line, so the log was verbose for # some types and silent for others. Reporting from here makes it consistent # for every type. The discriminators (@odata.type / @odata.id, plus the two # template/setting keys some types match on) are appended only when present. LogResolvedPolicy([PSCustomObject]$PolicyObject) { $name = $PolicyObject."$($this.NameProperty)" $parts = @() if($PolicyObject.'@odata.type') { $parts += "@odata.type: $($PolicyObject.'@odata.type')" } if($PolicyObject.'@odata.id') { $parts += "@odata.id: $($PolicyObject.'@odata.id')" } if($PolicyObject.templateReference.templateFamily) { $parts += "templateFamily: $($PolicyObject.templateReference.templateFamily)" } if($PolicyObject.settingDefinitionId) { $parts += "settingDefinitionId: $($PolicyObject.settingDefinitionId)" } $basis = if($parts.Count -gt 0) { " Found based on $($parts -join ', ')" } else { "" } # Verbose channel: this fires once per resolved object (every type, every # list/file load), so it is diagnostic noise in the normal log. Write-LogDebug # only emits when the Debug setting is on, keeping the default log clean while # the breadcrumb is still available when troubleshooting type resolution. Write-LogDebug "Found policy $name ($($this.Title)).$basis" } # Single-policy entry point preserved for back-compat with Compare and a # handful of subtype overrides (e.g. windows10CustomConfiguration OMA # decrypt). Routes through Invoke-PolicyHydrate so there is exactly one # hydration path; the body fetch / nav-property expansion / sub-resource # fan-out all live in the unified orchestrator. [Boolean]GetFullObject($PolicyObject) { if($PolicyObject._IsFullObject) { return $true } if($null -eq $PolicyObject._TokenId) { return $false } Invoke-PolicyHydrate -Policies @($PolicyObject) -TokenId $PolicyObject._TokenId if(-not $PolicyObject._IsFullObject) { Write-Warning "Failed to get full object for $($PolicyObject.Name)" return $false } return $true } # Returns a hashtable describing how to compare settings of this policy type, or $null for default property comparison. # Expected keys when non-null: # Prop : property name on .Object holding the settings array (required) # GetKey : scriptblock { param($s) ... } returning a stable string key per setting (required) # GetValue : scriptblock { param($s) ... } returning the display value per setting (required) # GetCategory : scriptblock { param($s) ... } returning the category label (optional) # Hydrate : scriptblock { param($policy) ... } loading any data missing after GetFullObject (optional) [Hashtable]GetCompareConfig() { return $null } PostExportCommand([PSCustomObject]$PolicyObject, [String]$PathToFile) { } Hidden PreExportCommand([IntunePolicyBase]$PolicyObject, [PSCustomObject]$ExportObject) { } [Hashtable]PreImportCommand([PSCustomObject]$PolicyObject) { return $null } [Hashtable]PreImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject) { return $null } PostImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject) { } PostImportAssignmentsCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject, [PSCustomObject]$ImportedAssignments) { } PostCopyCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject) { } PostBulkImportCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject) { } [Hashtable]PreDeleteCommand([IntunePolicyBase]$PolicyObject) { return $null } [Hashtable]PreUpdateCommand([PSCustomObject]$PolicyObject, [PSCustomObject]$SourceObject) { return $null } [Hashtable]PreReplaceCommand([IntunePolicyBase]$PolicyObject) { return $null } PostReplaceCommand([IntunePolicyBase]$PolicyObject, [IntunePolicyBase]$SourceObject) { } [IntunePolicyBase[]]PreImportPolicies([IntunePolicyBase[]]$PolicyObjects) { return @($PolicyObjects) } } class IntunePolicyBase { # Hidden Properties Hidden [IO.FileInfo]$FileInfoObject = $null Hidden [PSCustomObject]$JsonObject = $null Hidden [IntunePolicyTypeBase]$_PolicyType = $null Hidden [String]$_PolicyName = $null Hidden [boolean]$_IsFullObject = $false # Set to $true on subclasses that implement GetSubResourceBatchRequests / # ApplySubResourceBatchResult so the unified hydrator # (Invoke-PolicySubresourceFetch) can fan out their extra GETs in $batch # form during Invoke-PolicyHydrate. Hidden [boolean]$_HasSubResourceBatch = $false # Set by Start-GraphBulkExport when another policy in the same type bucket # shares this one's displayName, so GetFileName appends `_` and the # collision doesn't silently overwrite a sibling on disk. Hidden [boolean]$_NeedsIdInFilename = $false Hidden [string]$_PlatformName = $null Hidden [PSCustomObject]$_ClonedFromObject = $null Hidden [int]$_TokenID = $null Hidden [String[]]$_ScopeTags = $() Hidden [String]$_ScopeTagsString = $null [string]$TenantId = $null IntunePolicyBase() { ([IntunePolicyBase]$this).Init() } IntunePolicyBase([String]$Json) { ([IntunePolicyBase]$this).Init($Json) } IntunePolicyBase([PSCustomObject]$JsonObj) { ([IntunePolicyBase]$this).Init($JsonObj) } IntunePolicyBase([IO.FileInfo]$FileInfo) { ([IntunePolicyBase]$this).Init($FileInfo) } # Hidden Functions Hidden Init() { Add-ObjectProperty $this "ID" { return $this.GetId() } Add-ObjectProperty $this "Name" { return $this.GetName() } { $this.SetName($args[0]) } Add-ObjectProperty $this "PolicyType" { $this._PolicyType } Add-ObjectProperty $this "PolicyName" { $this.GetPolicyName() } Add-ObjectProperty $this "PolicyBaseName" { $this.GetPolicyBaseName() } # Per-row _PlatformName wins; if absent, fall back to the type's default. # Lets multiple subtypes share one Object class (e.g. DeviceEnrollment # buckets all use DeviceEnrollmentObject) and still report the right # Platform per bucket via type-level _PlatformName. Add-ObjectProperty $this "Platform" { if($this._PlatformName) { return $this._PlatformName } if($this._PolicyType -and $this._PolicyType._PlatformName) { return $this._PolicyType._PlatformName } return $null } Add-ObjectProperty $this "Object" { $this.JsonObject } # Bound by the grid (and therefore searchable by the filter) on every view. Add-ObjectProperty $this "Description" { if($this.JsonObject) { $this.JsonObject.description } } Add-ObjectProperty $this "IsFromFile" { $this.GetFromFile() } Add-ObjectProperty $this "FileInfo" { ([PSCustomObject]$this.FileInfoObject) } Add-ObjectProperty $this "IsFullObject" { $this._IsFullObject } Add-ObjectProperty $this "JsonString" { if($this.JsonObject) { ConvertTo-Json $this.JsonObject -Depth 50} } Add-ObjectProperty $this "TokenId" { $this._TokenId } Add-ObjectProperty $this "Created" { if($this.JsonObject.createdDateTime) { Get-Date $this.JsonObject.createdDateTime } else { "" } } Add-ObjectProperty $this "LastModified" { if($this.JsonObject.lastModifiedDateTime) { Get-Date $this.JsonObject.lastModifiedDateTime } elseif($this.JsonObject.modifiedDateTime) { Get-Date $this.JsonObject.modifiedDateTime } else { "" } } Add-ObjectProperty $this "ScopeTags" { if($null -eq $this._ScopeTags) { $this._ScopeTags = Get-GraphScopeTags $this $this._ScopeTagsString = $this._ScopeTags -join "," } return $this._ScopeTagsString } # Scope-tag NAMES as an array (same lazy resolution as ScopeTags, which # returns the comma-joined string). Lets callers use array operators — # e.g. a documentation filter `$_.ScopeTagNames -contains 'Production'`. Add-ObjectProperty $this "ScopeTagNames" { if($null -eq $this._ScopeTags) { $this._ScopeTags = Get-GraphScopeTags $this $this._ScopeTagsString = $this._ScopeTags -join "," } return $this._ScopeTags } # Both fields can carry a comma-separated flags value, so translation # goes through Get-PlatformDisplayName rather than a direct key lookup. if($this.JsonObject.platforms) { $this._PlatformName = Get-PlatformDisplayName $this.JsonObject.platforms } elseif($this.JsonObject.platformType) { $this._PlatformName = Get-PlatformDisplayName $this.JsonObject.platformType } if(-not $this._PlatformName -and $this.JsonObject.'@OData.Type'){ $this._PlatformName = Get-PolicyPlatformName $this.JsonObject.'@OData.Type' } } Hidden Init([string]$Json) { if($Json) { $this.LoadJson($Json) } ([IntunePolicyBase]$this).Init() } Hidden Init([PSCustomObject]$JsonObject) { $this.JsonObject = $JsonObject ([IntunePolicyBase]$this).Init() } Hidden Init([IO.FileInfo]$FileInfo) { if($FileInfo -and $FileInfo.Exists) { Write-LogDebug "Loading object from file $($FileInfo.FullName)" # ReadAllText, not Get-Content: this is the load path for every policy # read from disk (import, compare, file-based documentation). Bare # Get-Content decodes as the ANSI codepage on PS5.1, which mangles any # non-ASCII policy name or description on the way in. $Json = [IO.File]::ReadAllText($FileInfo.FullName) $this.LoadJson($Json) $this.FileInfoObject = $FileInfo } else { $errorString = "Failed to load object from file. File $($FileInfo.FullName) not found" Write-Log $errorString 3 throw $errorString } ([IntunePolicyBase]$this).Init() } Hidden LoadJson([String]$Json) { $this.JsonObject = $Json | ConvertFrom-Json } Hidden [String] GetName() { return $this.JsonObject."$($this.PolicyType._NameProperty)" } Hidden SetName($Name) { $this.JsonObject."$($this.PolicyType._NameProperty)" = $Name } Hidden [String] GetId() { return $this.JsonObject.id } Hidden [Boolean]GetFromFile() { return ($null -ne $this.FileInfoObject) } Hidden [String]GetPolicyName() { return ?? $this._PolicyName $this._PolicyType.PolicyName } Hidden [String]GetPolicyBaseName() { return ?? $this._PolicyBaseName $this._PolicyType.PolicyBaseName } # Public functions [String]GetObjectURL() { $params = @() $uri = [uri]"$($this.PolicyType.API)/$($this.id)" $expand = @() # NOTE: the property check below reads `.ExpandAssignments` not # `.ExpandAssignmentsList`. This LOOKS like a typo — and an earlier # session "fixed" it to ExpandAssignmentsList — but the wrong-name # behaviour is load-bearing here: # * `_ExpandAssignmentsList=$false` is meant to skip $expand=assignments # on the LIST URL because some Graph endpoints reject it there. # * For the per-id BODY URL, `$expand=assignments` is the only working # path for types whose `/assignments` sub-resource returns 400 # (managedAppPolicies, notificationMessageTemplates). # The typo'd check resolves to `$null -ne $false` (true), so the body # URL keeps the assignments expand regardless of the list-URL opt-out. # Correcting the name regressed AppProtection's actual assignment # rows to null on bulk export. if($this.JsonObject.'assignments@odata.navigationLink' -and $this.PolicyType.ExpandAssignments -ne $false) { $expand += "assignments" } if($this.JsonObject.'apps@odata.navigationLink') { $expand += "apps" } if($this.JsonObject.'settings@odata.navigationLink') { $expand += "settings" } if($this.JsonObject.'roleAssignments@odata.navigationLink') { $expand += "roleAssignments" } if($this.JsonObject.'privacyAccessControls@odata.associationLink') { $expand += "microsoft.graph.windows10GeneralConfiguration/privacyAccessControls" } if($this.PolicyType.Expand) { foreach($objExpand in $this.PolicyType.Expand.Split(",")) { if($objExpand -notin $expand) { $expand += $objExpand} } } if($expand.Count -gt 0) { $params += ('$expand=' + ($expand -join ",")) # ToDo: Check if expand is set } $url = $uri.OriginalString if($params.Count -gt 0) { $url += (?: ([String]::IsNullOrEmpty($uri.Query)) "?" "&") $url += ($params -join "&") } return $url } [Boolean]Get() { if($this._IsFullObject) { return $true } if($null -eq $this._TokenId) { return $false } Invoke-PolicyHydrate -Policies @($this) -TokenId $this._TokenId return $this._IsFullObject } # Bulk-export sub-resource contract. Override in subclasses that need to # fetch extra resources beyond the main object body (e.g. relationships, # script content, target apps). Phase 1 is the entry point; phases 2+ come # from follow-ups returned by ApplySubResourceBatchResult. # Each request: [PSCustomObject]@{ Key = ''; Url = ''; Method = 'GET' (optional); Headers = @{} (optional) } [PSCustomObject[]] GetSubResourceBatchRequests([int]$Phase) { return @() } # Routes a batched response back into the policy. Returns follow-up requests # for the next phase (empty array = done). $Body is $null for non-2xx # responses; the orchestrator already logged the HTTP status. [PSCustomObject[]] ApplySubResourceBatchResult([int]$Phase, [string]$Key, $Body) { return @() } # Called once per opted-in policy by Invoke-PolicySubresourceFetch AFTER all # phases complete. Override to build a derived property from the responses # already applied via ApplySubResourceBatchResult (e.g. AppConfiguration's # #CustomRefTargetedApps, which needs every targeted-app body resolved before # it can be assembled). Default no-op. Runs even when GetSubResourceBatchRequests # returned nothing, so a class can finalize from cache/state alone. [void] FinalizeSubResources() { } GetAssignments() { if(-not $this.Object.Assignments -and $null -ne $this._TokenId) { $url = "$($this._PolicyType.API)/$($this.id)/assignments" $assignments = (Invoke-MSGraphAPI -Url $url -TokenId $this._TokenId).Value if($assignments) { $this.Object.Assignments = $assignments } } } # Returns the specific objects this policy depends on. # Each entry: [PSCustomObject]@{ TypeId = ""; Id = "" } # Override in derived classes to enable targeted dependency loading. # The base implementation returns an empty array (falls back to type-level _Dependencies). [PSCustomObject[]] GetDependencyReferences() { return @() } [String]GetFileName() { # `$this.` is required: a bare `GetFileName($null)` is parsed as a COMMAND # invocation, not a method call, so this overload threw "The term # 'GetFileName' is not recognized" for every caller that omitted the path. return ($this.GetFileName($null)) } [String]GetFileName([String]$Path) { $fileName = $this.Name.Trim('.') # Append `_` to the filename when EITHER the user-facing setting # AddIDToExportFile is on (matches the OLD project's behaviour) OR the # bulk-export collision detector flagged this specific policy because # another policy in the same type bucket shares its displayName. # Without the collision case, exporting N same-named policies would # silently overwrite the same file N times and only the last one would # survive on disk (seen in a lab tenant: 81 CA policies -> 39 unique # displayName -> 42 lost without this guard). $forceId = (Get-SettingValue "AddIDToExportFile") -eq $true -or $this._NeedsIdInFilename -eq $true if($forceId -and $this.Id -and $this.PolicyType.SkipAddIDOnFileName -ne $true) { $fileName = ($fileName + "_" + $this.Id) } $fileName = "$((Remove-InvalidFileNameChars $fileName)).json" if($Path) { $fileName = [IO.Path]::Combine($Path, $fileName) } return $fileName } [string]ExportToFile([String]$ParentFolder) { return ($this.ExportToFile($ParentFolder, $null)) } [string]ExportToFile([String]$ParentFolder, [String]$FileName) { $returnValue = $null if(-not $FileName) { $FileName = $this.GetFileName($ParentFolder) } # Clone JsonObject via JSON round-trip so PreExportCommand can mutate # without affecting the in-memory policy. Same pattern as Clone(). $exportObject = ConvertFrom-Json (ConvertTo-Json $this.JsonObject -Depth 50) $this.PolicyType.PreExportCommand($this, $exportObject) # Central serializer so the policy file honours the same # SortJsonProperties / ExportJsonFormat settings as the MigrationTable # and the sidecars. It previously called ConvertTo-Json directly, which # meant SortJsonProperties was silently ignored for policy exports. $json = ConvertTo-GraphExportJson $exportObject -Depth 50 # Organization values -> placeholders. Internal/ExportTokens.ps1 owns the # replace, the "Replace organization values in export files" setting and # the hidden ExportReplaceTokens key that narrows which values are # replaced. # # Masked against THIS policy's own organization, not the default token's: # exporting a policy listed from tenant A while tenant B is the default # otherwise masked B's id in A's data and left A's real tenant id in the # file. Same reasoning as Save-GraphObjectToFile does for the sidecars. $orgInfo = Get-GraphObjectOrganizationInfo $this $json = Convert-GraphOrganizationValueToToken $json ` -OrganizationId $orgInfo.OrganizationId ` -OrganizationName $orgInfo.OrganizationName try { # [IO.File]::WriteAllText is ~10x faster than Out-File for this hot path # (called once per exported policy). Out-File goes through PowerShell's # pipeline + .NET StreamWriter wrapper with Format-Table conversion; the # direct .NET call skips all that. # # Encoding comes from the "Export file encoding" setting, which every # exported file shares - policy JSON, the MigrationTable and the # assignment sidecars. This used to hard-code UTF-8 WITH BOM, and the # sidecars used a bare Out-File (UTF-16LE on PS5.1). Consumers that read # the export as plain UTF-8 - git, the Intune portal, other tooling - # choke on a BOM, so the default is now UTF-8 without one. [System.IO.File]::WriteAllText($fileName, $json, (Get-ExportFileEncoding)) $returnValue = $fileName } catch { Write-LogError "Failed to save file $fileName" $_.Exception } return $returnValue } Hidden SetDescription($Description) { if($this.HasDescription -ne $false) { $this.JsonObject.description = $Description } } Hidden [IntunePolicyBase]Clone() { $cloned = $this._PolicyType.GetObject((ConvertTo-Json $this.JsonObject -Depth 50)) $cloned._ClonedFromObject = ?? $this._ClonedFromObject $this $cloned.TenantId = $this.TenantId # Import runs against the clone, and PreImportCommand hooks read FileInfo to # find files that sit next to the exported json - the ADMX/ADML pair, the # Terms of Use PDFs. Rebuilding the clone from JSON alone dropped it, so # those lookups saw $null and silently fell back to the app packages folder. $cloned.FileInfoObject = $this.FileInfoObject return $cloned } Hidden [IntunePolicyBase]CopyObject([String]$Name, [String]$Description, [int]$ToTokenId) { # Backward-compat overload — inherits scope tags from the source. return $this.CopyObject($Name, $Description, $ToTokenId, $null) } Hidden [IntunePolicyBase]CopyObject([String]$Name, [String]$Description, [int]$ToTokenId, [string[]]$ScopeTagIds) { Write-Log "Copy $($this.PolicyType.Title) object $($this.Name)" if($this.IsFromFile -eq $false -and $this.IsFullObject -eq $false -and $this._RequiresFullObject -ne $false) { $this.Get() | Out-Null } $clonedObject = $this.Clone() if($clonedObject) { if($clonedObject._RequiresFullObject -eq $true) { [void]$clonedObject.Get() } $clonedObject.SetName($Name) if($Description) { $clonedObject.SetDescription($Description) } # Override scope tags on the clone when the caller passed an explicit # list. $null = leave as-is (inherit). Empty array = explicit reset # (Intune will re-apply Default automatically in that case). if($null -ne $ScopeTagIds -and $clonedObject.PolicyType.ScopeTagProperty) { $stProp = [string]$clonedObject.PolicyType.ScopeTagProperty if($clonedObject.JsonObject.PSObject.Properties[$stProp]) { $clonedObject.JsonObject.$stProp = @($ScopeTagIds) } else { $clonedObject.JsonObject | Add-Member -MemberType NoteProperty -Name $stProp -Value @($ScopeTagIds) -Force } } Add-GraphNavigationProperties $this $newObj = $clonedObject.ImportObject($ToTokenId, $null) if(-not $newObj) { return $null } if($newObj.PolicyType.NavigationProperties -eq $true) { Set-GraphNavigationProperties $newObj $this } $newObj.PolicyType.PostCopyCommand($newObj, $this) return $newObj } return $null } Hidden [PSCustomObject]ImportObject([int]$TokenId, [HashTable]$BulkImport) { # Get-OperationTokenInfo, not Get-TokenInfo: the latter treats 0 as "list # every token", and 0 is also how a caller spells "the default token". With # two tenants signed in that made $tokenInfo an ARRAY, so .TenantId below # restored %OrganizationId% as two space-joined guids. $tokenInfo = Get-OperationTokenInfo $TokenId Write-Log "Import $($this.PolicyType.Title) object '$($this.Name)' to $($tokenInfo.TenantName)" $clonedObject = $this.Clone() Remove-GraphPropertiesForImport $clonedObject $clonedObject.JsonObject $params = @{} $strAPI = (?? $clonedObject.PolicyType.APIPOST $clonedObject.PolicyType.API) $method = "POST" $ret = $clonedObject.PolicyType.PreImportCommand($clonedObject) if($ret -is [HashTable]) { if($ret.ContainsKey("Import") -and $ret["Import"] -eq $false) { # Import handled manually return $false } if($ret.ContainsKey("API")) { $strAPI = $ret["API"] } if($ret.ContainsKey("Method")) { $method = $ret["Method"] } if($ret.ContainsKey("AdditionalHeaders") -and $ret["AdditionalHeaders"] -is [HashTable]) { $params.Add("AdditionalHeaders",$ret["AdditionalHeaders"]) } } # Resolve scope tags on the cloned body before serialization (ImportScopeTags # setting + same/cross-tenant remap). Keeps source IDs same-tenant, remaps # cross-tenant, or resets to Default when the setting is off. Set-GraphImportScopeTags $clonedObject $TokenId $json = ConvertTo-Json $clonedObject.JsonObject -Depth 50 if($tokenInfo -and $clonedObject._ClonedFromObject.TenantId -ne $tokenInfo.TenantId) { # Call Update-JsonForEnvironment before importing the object # E.g. PolicySets contains references, AppConfiguration policies reference apps etc. $json = Update-JsonForEnvironment $json $clonedObject $TokenId } # Placeholders -> values. Always runs: an export made when the replace # setting was on carries placeholders whatever it is set to now. # Internal/ExportTokens.ps1 decides which placeholders are restored. # # Restored to the TARGET tenant of this import, not to whichever tenant # happens to hold the default token: importing into tenant B on a # non-default token used to write tenant A's GUID into B's policy. $json = Convert-GraphOrganizationTokenToValue $json ` -OrganizationId $tokenInfo.TenantId ` -OrganizationName $tokenInfo.TenantName if($BulkImport) { Write-Status "Add $($this.Name) ($($this.PolicyName)) with id $($this.Id) to Bulk Import list" $bulkImportObject = [PSCustomObject]@{ id = ($this.Name + "_" + (New-Guid).Guid.SubString(0,8)) method = $method url = $strAPI.TrimStart('/') headers = @{"Content-Type"="application/json;odata.metadata=none"} body = $json | ConvertFrom-Json #[System.Text.Encoding]::UTF8.GetBytes($json) } $BulkImport.Add($bulkImportObject, [PSCustomObject]@{ ImportObject = $clonedObject FromObject = $this }) return $null } $newObj = $null $responseObject = $clonedObject.ImportObject($strAPI, $json, $method, $TokenId, $params) $newObj = $clonedObject.ProcessImportResponse($TokenId, $responseObject, $method) return $newObj } Hidden [PSCustomObject]ImportObject([string]$API,[string]$Json, [string]$Method, [int]$TokenID, [Hashtable]$Params) { $responseObject = (Invoke-MSGraphAPI -Url $API -Content $Json -HttpMethod $Method -TokenId $TokenID -FullResponseObject @Params) return $responseObject } Hidden [IntunePolicyBase]UpdateObject([IntunePolicyBase]$ExistingObject, [int]$TokenId) { if($null -eq $ExistingObject) { Write-Log "Cannot update $($this.PolicyType.Title) object '$($this.Name)'. Existing object was not supplied." 3 return $null } # One token, never the whole list - see ImportObject above. $tokenInfo = Get-OperationTokenInfo $TokenId Write-Log "Update $($this.PolicyType.Title) object '$($ExistingObject.Name)' from imported object '$($this.Name)'" $updateObject = $this.Clone() $targetId = $ExistingObject.Id if(-not $targetId) { Write-Log "Cannot update $($this.PolicyType.Title) object '$($this.Name)'. Existing object has no id." 3 return $null } if($updateObject.JsonObject.PSObject.Properties['id']) { $updateObject.JsonObject.id = $targetId } else { $updateObject.JsonObject | Add-Member -MemberType NoteProperty -Name 'id' -Value $targetId -Force } $updateObject._TokenID = $TokenId if($tokenInfo) { $updateObject.TenantId = $tokenInfo.TenantId } $params = @{} $strAPI = (?? $updateObject.PolicyType.APIPUT $updateObject.PolicyType.API) + "/$targetId" $method = "PATCH" $ret = $updateObject.PolicyType.PreUpdateCommand($updateObject, $ExistingObject) if($ret -is [HashTable]) { if($ret.ContainsKey("Update") -and $ret["Update"] -eq $false) { Write-Log "Update skipped by $($updateObject.PolicyType.Title) pre-update command for '$($ExistingObject.Name)'" 2 return $null } if($ret.ContainsKey("API")) { $strAPI = $ret["API"] } if($ret.ContainsKey("Method")) { $method = $ret["Method"] } if($ret.ContainsKey("AdditionalHeaders") -and $ret["AdditionalHeaders"] -is [HashTable]) { $params.Add("AdditionalHeaders",$ret["AdditionalHeaders"]) } } Remove-GraphPropertiesForImport $updateObject $updateObject.JsonObject # isAssigned is a computed status flag many endpoints refuse to accept # back on PATCH - the original project stripped it on every update. Remove-Property $updateObject.JsonObject "isAssigned" foreach($propertyToRemove in @($updateObject.PolicyType.PropertiesToRemoveForUpdate)) { Remove-Property $updateObject.JsonObject $propertyToRemove } $json = ConvertTo-Json $updateObject.JsonObject -Depth 50 if($tokenInfo -and $updateObject._ClonedFromObject.TenantId -ne $tokenInfo.TenantId) { $json = Update-JsonForEnvironment $json $updateObject $TokenId } # Placeholders -> values. Always runs: an export made when the replace # setting was on carries placeholders whatever it is set to now. # Internal/ExportTokens.ps1 decides which placeholders are restored. # # Restored to the TARGET tenant of this import, not to whichever tenant # happens to hold the default token: importing into tenant B on a # non-default token used to write tenant A's GUID into B's policy. $json = Convert-GraphOrganizationTokenToValue $json ` -OrganizationId $tokenInfo.TenantId ` -OrganizationName $tokenInfo.TenantName $responseObject = Invoke-MSGraphAPI -Url $strAPI -Content $json -HttpMethod $method -TokenId $TokenId -FullResponseObject @params if($responseObject.Success) { $ExistingObject._TokenID = $TokenId if($tokenInfo) { $ExistingObject.TenantId = $tokenInfo.TenantId } [void]$ExistingObject.Get() Write-Log "$($ExistingObject.PolicyType.Title) object updated successfully: $($ExistingObject.Name) ($($ExistingObject.Id))" Import-GraphObjectAssignment $ExistingObject $this return $ExistingObject } # Include the Graph error body - StatusDescription alone ("Bad # Request") says nothing about WHY the update was rejected. $errorDetail = "" try { $errorContent = if($responseObject.Content -is [string]) { $responseObject.Content | ConvertFrom-Json } else { $responseObject.Content } if($errorContent.error.message) { $errorDetail = " - $($errorContent.error.message)" } } catch { } Write-Log "Failed to update $($ExistingObject.PolicyType.Title) object '$($ExistingObject.Name)' ($($ExistingObject.Id)). $($responseObject.StatusCode) $($responseObject.StatusDescription)$errorDetail" 3 return $null } Hidden [PSCustomObject]ProcessImportResponse([int]$TokenId, [PSCustomObject]$Response, [String]$Method) { $newObj = $null # One token, never the whole list - see ImportObject above. $tokenInfo = Get-OperationTokenInfo $TokenId if($Response.Success) { if($method -eq "POST" -and $Response.Content) { $newObj = $this.PolicyType.GetObject($Response.Content) if(-not $newObj -and $this.PolicyType._ObjectClass) { # GetObject runs CheckPolicy for _VerifyObject types and can # reject a minimal POST response body (e.g. Settings Catalog # create responses may omit templateReference). We KNOW the # object belongs to this type - we just created it - so # construct it directly without verification. $newObj = New-Object -TypeName $this.PolicyType._ObjectClass -ArgumentList $Response.Content } } else { [void]$this.Get() $newObj = $this } if($tokenInfo) { $newObj._TokenID = $tokenInfo.Id $newObj.TenantId = $tokenInfo.TenantId } else { $newObj._TokenID = $TokenId try { $userInfo = (Get-AuthProvider).GetUserInfo($TokenId) if($userInfo -and $userInfo.TenantId) { $newObj.TenantId = $userInfo.TenantId } } catch { } } Write-Log "$($newObj.PolicyType.Title) object imported successfully: $($newObj.Name) ($($newObj.Id))" # PostImportCommand post-processes using SOURCE data that was stripped # from this clone before the create POST (AdminTemplate definitionValues, # app script/content file info, ...). $this here is the stripped clone, # so hand it the ORIGINAL via _ClonedFromObject; fall back to $this for a # direct (non-clone) call. $sourceObj = $this._ClonedFromObject if($null -eq $sourceObj) { $sourceObj = $this } $newObj.PolicyType.PostImportCommand($newObj, $sourceObj) Import-GraphObjectAssignment $newObj $this } return $newObj } Hidden [bool]Set([string]$Json) { return ($this.Set($null, $Json, $null, $null)) } Hidden [bool]Set([string]$API, [string]$Json) { return ($this.Set($API, $Json, $null, $null)) } Hidden [bool]Set([string]$API, [string]$Json, [string]$Method, [Hashtable]$Params) { if($null -eq $Json) { Write-Log "Cannot update object without Json data" return $false } if(-not $API) { $API = (?? $this.PolicyType.APIPOST $this.PolicyType.API) + "/$($this.Id)" } if($null -ne $this._TokenID) { $tokenId = $this._TokenID } else { Write-Log "Updating object $($this.Name) ($($this.Id)) with default token. TokenId not set on object" 2 $tokenId = 0 } if(-not $Method) { $Method = "PATCH" } $responseObject = (Invoke-MSGraphAPI -Url $API -Content $Json -HttpMethod $Method -TokenId $tokenId -FullResponseObject @Params) if($responseObject.Success) { Write-Log "$($this.Name) ($($this.Id)) updated successfully" [void]$this.Get() } return $responseObject.Success } Hidden [bool]Delete([HashTable]$BulkDelete) { $deleteAPI = $null $ret = $this.PolicyType.PreDeleteCommand($this) if($ret -is [HashTable]) { if($ret.ContainsKey("Delete") -and $ret["Delete"] -eq $false) { # Delete handled manually or aborted return $false } if($ret.ContainsKey("API")) { $deleteAPI = $ret["API"] } } if(-not $deleteAPI) { $deleteAPI = $this.PolicyType.APIDELETE } if($BulkDelete -is [HashTable]) { Write-Status "Add $($this.Name) ($($this.PolicyName)) with id $($this.Id) to Bulk Delete list" $url = ($deleteAPI + "/$($this.Id)") $bulkDeleteObject = [PSCustomObject]@{ id = $this.Id method = "DELETE" url = $url.TrimStart('/') headers = @{"Accept"="application/json;odata.metadata=none"} } $BulkDelete.Add($bulkDeleteObject, $this) } else { Write-Status "Delete $($this.Name) ($($this.PolicyName)) with id $($this.Id)" $deleteAPI = ($deleteAPI + "/$($this.Id)") $response = Invoke-MSGraphAPI -Url $deleteAPI -HttpMethod "DELETE" -ODataMetadata "none" -FullResponseObject if($response.Success) { Write-LogDebug "Policy deleted successfully" } return $response.Success } return $true } } <# class IntuneImportExportPolicyBase : PSCustomObject, System.ComponentModel.INotifyPropertyChanged { [Boolean]$IsSelected = $true [Boolean]$_AddObjectType = $null [Boolean]$_AddCompanyName = $null [System.Collections.ArrayList]$PropertyChanged = @() IntuneImportExportPolicyBase() { $this.PSObject._AddObjectType = (Get-SettingValue "AddObjectType") $this.PSObject._AddCompanyName = (Get-SettingValue "AddCompanyName") Add-ObjectProperty $this "AddObjectType" { return $this.PSObject._AddObjectType } { param([Boolean]$Value) $this.PSObject._AddObjectType = $Value; $this.PSObject.psobject.NotifyPropertyChanged('AddObjectType') } Add-ObjectProperty $this "AddCompanyName" { return $this.PSObject._AddObjectType } { param([Boolean]$Value) $this.psobject._AddCompanyName = $Value; $this.psobject.NotifyPropertyChanged('AddCompanyName') } } add_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) { $this.psobject.PropertyChanged.Add($handler) } remove_PropertyChanged([System.ComponentModel.PropertyChangedEventHandler]$handler) { $this.psobject.PropertyChanged.Remove($handler) } NotifyPropertyChanged([string]$propname) { if ($this.psobject.PropertyChanged) { $evargs = [System.ComponentModel.PropertyChangedEventArgs]::new($propname) $this.psobject.PropertyChanged.Invoke($this, $evargs) # invokes every member } } } class IntuneExportPolicy : IntuneImportExportPolicyBase { [string]$_ExportFolder = $null [Boolean]$_AddExportAssignments = $false IntuneExportPolicy() : Base() { $this.PSObject._ExportFolder = (?? (Get-SettingStoreValue "" "LastUsedRoot") (Get-SettingValue "RootFolder")) $this.PSObject._AddExportAssignments = (Get-SettingValue "ExportAssignments") Add-ObjectProperty $this "ExportFolder" { return $this.PSObject._ExportFolder } ({ param([String]$Value) $this.psobject._ExportFolder = $Value; $this.psobject.NotifyPropertyChanged('ExportFolder') }) Add-ObjectProperty $this "AddExportAssignments" { return $this.PSObject._AddExportAssignments } { param([Boolean]$Value) $this.psobject._AddExportAssignments = $Value; $this.psobject.NotifyPropertyChanged('AddExportAssignments') } } } #> class IntuneManagerExportSettings { [Boolean]$AddObjectType = $true [Boolean]$AddCompanyName = $true [string]$Filter = $null [Boolean]$ExportAssignments = $false # Depth of group-membership recursion. 1 (default) = only the group directly # assigned to the policy. 2 = also exports groups that are direct members # of the assigned group. 3 = one more level. Cycle-safe via the existing # per-folder MigrationTable dedup index. [Int]$ExportNestedGroupLevels = 1 Hidden [string]$ExportFolder = $null IntuneManagerExportSettings() { $this.AddObjectType = (Get-SettingValue "AddObjectType") $this.AddCompanyName = (Get-SettingValue "AddCompanyName") $this.ExportAssignments = (Get-SettingValue "ExportAssignments") $this.ExportFolder = (?? (Get-SettingStoreValue "" "LastUsedRoot") (Get-SettingValue "RootFolder")) # Setting is registered as String so the form can use a free-form # editor; coerce to Int with a floor of 1 here so consumers can treat # it as a plain integer. $rawDepth = Get-SettingValue "ExportNestedGroupLevels" "1" $parsed = 0 if([int]::TryParse([string]$rawDepth, [ref]$parsed) -and $parsed -ge 1) { $this.ExportNestedGroupLevels = $parsed } else { $this.ExportNestedGroupLevels = 1 } } Save() { Save-SettingStoreValue "" "LastUsedRoot" $this.ExportFolder } } class IntuneManagerImportSettings { [string]$ImportFolder = $null [String]$MigrationTableInfo = $false [Boolean]$ImportAssignments = $false [Boolean]$ImportScopeTags = $false [Boolean]$ReplaceDependencyIDs = $false [Boolean]$SameTenant = $false [String]$ImportType = $null IntuneManagerImportSettings() { $path = Get-SettingStoreValue "" "LastUsedFullPath" $this.ImportFolder =(?? $path (Get-SettingValue "RootFolder")) $this.ImportAssignments = (Get-SettingValue "ImportAssignments") $this.ImportScopeTags = (Get-SettingValue "ImportScopeTags") $this.ImportType = (Get-SettingValue "ImportType" "alwaysImport") $this.ReplaceDependencyIDs = (Get-SettingValue "ResolveReferenceInfo") -ne $false } Save() { # Persist the per-import toggles to the SAME SubPath they are registered # under ("IntuneManager") so the import pipeline reads them back via # Get-SettingValue. (Was a broken copy of the export Save() that wrote a # non-existent $this.ExportFolder.) Save-SettingStoreValue "IntuneManager" "ImportAssignments" $this.ImportAssignments Save-SettingStoreValue "IntuneManager" "ImportScopeTags" $this.ImportScopeTags Save-SettingStoreValue "IntuneManager" "ResolveReferenceInfo" $this.ReplaceDependencyIDs if($this.ImportType) { Save-SettingStoreValue "IntuneManager" "ImportType" $this.ImportType } } } # State holder for Bulk Scope Tags. UI binds to it; Set-GraphBulkScopeTags # reads it. Kept separate from IntuneManagerImportSettings because the two # operations have unrelated knobs and a shared class would force every UI to # carry the union. class IntuneManagerScopeTagSettings { # Action applied to every selected policy: # Add — union of current tag list + ScopeTagIds (no-op if all already present) # Replace — overwrite current list with ScopeTagIds # Remove — remove ScopeTagIds from the current list [string]$Action = "Add" # IDs of the scope tags the user chose in the picker. May be empty when # CleanupOrphans is the only thing the user wants done. [String[]]$ScopeTagIds = @() # Name filter — matched as regex (falls back to literal substring if not # a valid regex). Empty = touch every listed policy. [string]$Filter = $null # When true, any tag id on a policy that doesn't resolve to a real tag in # the tenant's current catalogue is stripped from the resulting list. Lets # users clean up references to deleted tags as a side-effect of any action, # or run a pure cleanup pass (Action=Add with no ScopeTagIds). [Boolean]$CleanupOrphans = $false } # State holder for Bulk Assignments (Phase 1: group / exclusion-group / all- # devices / all-users targets with optional assignment filters; no app intent # or per-platform settings yet — those land in later phases). class IntuneManagerAssignmentSettings { # Action applied to every selected policy: # Add — union of current assignments + chosen Assignments (no-op if # all already present, compared by target type + groupId + # filterId + filterType) # Replace — overwrite current assignments with chosen Assignments # Remove — remove the chosen Assignments from the current list [string]$Action = "Add" # User-chosen assignments to apply. Each entry is a PSCustomObject: # @{ # TargetType = 'groupAssignmentTarget' | 'exclusionGroupAssignmentTarget' # | 'allDevicesAssignmentTarget' | 'allLicensedUsersAssignmentTarget' # GroupId = (group/exclusion targets only) # GroupName = (display only) # FilterId = (optional, group/exclusion only) # FilterName = (display only) # FilterType = 'include' | 'exclude' (when FilterId set) # } # PSCustomObject[] (not a typed class) so the UI can mutate in place and # the public command can ingest without an extra projection step. [PSCustomObject[]]$Assignments = @() # Name filter — matched as regex (falls back to literal substring if not # a valid regex). Empty = touch every listed policy. [string]$Filter = $null }