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,396 @@
|
||||
# Per-policy Entra group + assignment-filter migration on IMPORT.
|
||||
#
|
||||
# When a policy exported from another tenant is imported, its json still carries the
|
||||
# SOURCE tenant's group ids (assignments, CA include/exclude groups, notification CC
|
||||
# lists, role members, ...), assignment-filter ids (deviceAndAppManagementAssignment-
|
||||
# FilterId on every target type) and - for some payloads - group SIDs. This module
|
||||
# resolves ONLY the references the imported policy actually carries (unlike the 3.x
|
||||
# behavior of bulk-creating every group in the migration table up front):
|
||||
#
|
||||
# 1. Update-JsonForEnvironment scans the policy json for every GUID and SID
|
||||
# (Get-DependencyIDs) and hands them to Resolve-GraphMigrationGroups.
|
||||
# 2. A GUID/SID is only considered when it matches an EXPORTED group - the
|
||||
# Groups\*.json sidecars plus the MigrationTable.json Group entries under the
|
||||
# import folder's export root. Anything else is ignored (no Graph calls).
|
||||
# 3. Each matched group is resolved in the target tenant: by id first (fast path,
|
||||
# also covers already-migrated ids), then by displayName.
|
||||
# 4. Missing groups are CREATED - gated on the CreateGroupOnImport setting - from
|
||||
# the sidecar stripped to cloud-creatable properties (dynamic groups keep their
|
||||
# membershipRule/groupTypes). A group whose sidecar shows onPremisesSyncEnabled
|
||||
# is an AD-synced group: ConvertSyncedGroupOnImport=true recreates it as a
|
||||
# cloud Entra group, false skips it with a warning.
|
||||
# 5. The returned maps translate source id -> target id and source SID -> target
|
||||
# SID in the policy json.
|
||||
#
|
||||
# Results are cached per (export root + target tenant) so a group shared by many
|
||||
# policies in one bulk import is resolved/created exactly once.
|
||||
|
||||
# --- caches -------------------------------------------------------------------
|
||||
# $script:_groupSidecarIndex : exportRoot -> @{ ById = @{ id -> entry }; BySid = @{ sid -> entry } }
|
||||
# entry = @{ Id; Name; Sid; Synced; Sidecar (full object or $null) }
|
||||
# $script:_groupMigrationMap : "exportRoot|targetTenant" -> @{ sourceId -> resolved @{ Id; Sid } or $null (unresolvable) }
|
||||
|
||||
function Reset-GraphGroupMigrationCache
|
||||
{
|
||||
$script:_groupSidecarIndex = @{}
|
||||
$script:_groupMigrationMap = @{}
|
||||
$script:_filterListCache = @{}
|
||||
}
|
||||
|
||||
# Resolve the export root for a policy loaded from file: the folder that holds
|
||||
# MigrationTable.json / Groups\. Probes the file's own folder first (AddObjectType
|
||||
# = false exports) then its parent (the normal per-type subfolder layout) - same
|
||||
# 2-level heuristic as Get-GraphMigrationTableFromPath.
|
||||
function Get-GraphGroupMigrationRoot
|
||||
{
|
||||
param($PolicyObject)
|
||||
|
||||
$fileInfo = ?? $PolicyObject._ClonedFromObject.FileInfo $PolicyObject.FileInfo
|
||||
if(-not $fileInfo) { return $null }
|
||||
|
||||
$folder = [IO.Path]::GetDirectoryName($fileInfo.FullName)
|
||||
for($i = 0; $i -lt 2 -and $folder; $i++)
|
||||
{
|
||||
if([IO.File]::Exists([IO.Path]::Combine($folder, "MigrationTable.json")) -or
|
||||
[IO.Directory]::Exists([IO.Path]::Combine($folder, "Groups")) -or
|
||||
[IO.Directory]::Exists([IO.Path]::Combine($folder, "AssignmentFilters")))
|
||||
{
|
||||
return $folder
|
||||
}
|
||||
$folder = [IO.Path]::GetDirectoryName($folder)
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Build (and cache) the exported-group index for an export root: every group the
|
||||
# export knows about, keyed by source id and by SID. Sidecars win over bare
|
||||
# migration-table entries (they carry the full object for re-creation).
|
||||
function Get-GraphGroupSidecarIndex
|
||||
{
|
||||
param([string]$ExportRoot)
|
||||
|
||||
if(-not $script:_groupSidecarIndex) { $script:_groupSidecarIndex = @{} }
|
||||
if($script:_groupSidecarIndex.ContainsKey($ExportRoot)) { return $script:_groupSidecarIndex[$ExportRoot] }
|
||||
|
||||
$index = @{ ById = @{}; BySid = @{} }
|
||||
|
||||
$groupsPath = [IO.Path]::Combine($ExportRoot, "Groups")
|
||||
if([IO.Directory]::Exists($groupsPath))
|
||||
{
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($groupsPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
$g = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
if(-not $g.id) { continue }
|
||||
$entry = @{
|
||||
Kind = "Group"
|
||||
Id = [string]$g.id
|
||||
Name = [string]$g.displayName
|
||||
Sid = [string]$g.securityIdentifier
|
||||
Synced = ($g.onPremisesSyncEnabled -eq $true)
|
||||
Sidecar = $g
|
||||
}
|
||||
$index.ById[$entry.Id] = $entry
|
||||
if($entry.Sid) { $index.BySid[$entry.Sid] = $entry }
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse group sidecar $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Assignment-filter sidecars (written by Add-GraphMigrationObject; the folder name
|
||||
# matches the AssignmentFilters type's normal export folder, so a full bulk export
|
||||
# that included the type feeds the same index).
|
||||
$filtersPath = [IO.Path]::Combine($ExportRoot, "AssignmentFilters")
|
||||
if([IO.Directory]::Exists($filtersPath))
|
||||
{
|
||||
foreach($file in [IO.Directory]::EnumerateFiles($filtersPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
$f = ConvertFrom-Json ([IO.File]::ReadAllText($file))
|
||||
if(-not $f.id) { continue }
|
||||
$index.ById[[string]$f.id] = @{
|
||||
Kind = "Filter"
|
||||
Id = [string]$f.id
|
||||
Name = [string]$f.displayName
|
||||
Sid = $null
|
||||
Synced = $false
|
||||
Sidecar = $f
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse assignment-filter sidecar $file" 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Migration-table entries without a sidecar (older exports): name-only.
|
||||
$migFile = [IO.Path]::Combine($ExportRoot, "MigrationTable.json")
|
||||
if([IO.File]::Exists($migFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
$migObj = ConvertFrom-Json ([IO.File]::ReadAllText($migFile))
|
||||
foreach($m in @($migObj.Objects))
|
||||
{
|
||||
if($m.Type -notin @("Group","AssignmentFilter") -or -not $m.Id) { continue }
|
||||
if($index.ById.ContainsKey([string]$m.Id)) { continue }
|
||||
$index.ById[[string]$m.Id] = @{
|
||||
Kind = if($m.Type -eq "AssignmentFilter") { "Filter" } else { "Group" }
|
||||
Id = [string]$m.Id
|
||||
Name = [string]$m.DisplayName
|
||||
Sid = $null
|
||||
Synced = $false
|
||||
Sidecar = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Write-Log "Failed to parse migration table $migFile" 2
|
||||
}
|
||||
}
|
||||
|
||||
$script:_groupSidecarIndex[$ExportRoot] = $index
|
||||
return $index
|
||||
}
|
||||
|
||||
# Create a group in the target tenant from an exported entry. Sidecar-based when
|
||||
# available (stripped to cloud-creatable properties; dynamic groups keep their
|
||||
# rule), else a default cloud security group. Returns the created group or $null.
|
||||
function New-GraphMigrationGroup
|
||||
{
|
||||
param($Entry, [int]$TokenId)
|
||||
|
||||
$keepProps = @("displayName","description","mailEnabled","mailNickname","securityEnabled",
|
||||
"membershipRule","groupTypes","membershipRuleProcessingState")
|
||||
|
||||
$body = [ordered]@{}
|
||||
if($Entry.Sidecar)
|
||||
{
|
||||
foreach($prop in $Entry.Sidecar.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -notin $keepProps) { continue }
|
||||
if($null -eq $prop.Value) { continue }
|
||||
$body[$prop.Name] = $prop.Value
|
||||
}
|
||||
}
|
||||
|
||||
if(-not $body["displayName"]) { $body["displayName"] = $Entry.Name }
|
||||
if(-not $body["displayName"]) { return $null }
|
||||
$body["displayName"] = ([string]$body["displayName"]).Trim()
|
||||
if(-not $body.Contains("mailEnabled")) { $body["mailEnabled"] = $false }
|
||||
if(-not $body.Contains("securityEnabled")) { $body["securityEnabled"] = $true }
|
||||
# mailNickname is mandatory on POST /groups and often null on synced/security groups.
|
||||
if(-not $body["mailNickname"]) { $body["mailNickname"] = (New-Guid).Guid.SubString(0, 10) }
|
||||
|
||||
Write-Log "Creating Entra group '$($body["displayName"])' in target tenant (referenced by imported policy)"
|
||||
return Invoke-MSGraphAPI -Url "/groups" -HttpMethod "POST" -Content (ConvertTo-Json $body -Depth 10) -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Create an assignment filter in the target tenant from an exported sidecar. Filters
|
||||
# without a sidecar (table-only entries) cannot be created - platform + rule are
|
||||
# mandatory and unknowable from the name alone.
|
||||
function New-GraphMigrationFilter
|
||||
{
|
||||
param($Entry, [int]$TokenId)
|
||||
|
||||
if(-not $Entry.Sidecar) {
|
||||
Write-Log "Assignment filter '$($Entry.Name)' has no exported sidecar (platform/rule unknown) - cannot create it in the target tenant" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
$keepProps = @("displayName","description","platform","rule","assignmentFilterManagementType")
|
||||
$body = [ordered]@{}
|
||||
foreach($prop in $Entry.Sidecar.PSObject.Properties)
|
||||
{
|
||||
if($prop.Name -notin $keepProps) { continue }
|
||||
if($null -eq $prop.Value) { continue }
|
||||
$body[$prop.Name] = $prop.Value
|
||||
}
|
||||
if(-not $body["displayName"] -or -not $body["rule"] -or -not $body["platform"]) {
|
||||
Write-Log "Assignment filter sidecar for '$($Entry.Name)' is missing displayName/platform/rule - cannot create" 2
|
||||
return $null
|
||||
}
|
||||
|
||||
Write-Log "Creating assignment filter '$($body["displayName"])' in target tenant (referenced by imported policy)"
|
||||
return Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters" -HttpMethod "POST" -Content (ConvertTo-Json $body -Depth 10) -TokenId $TokenId
|
||||
}
|
||||
|
||||
# Target-tenant assignment-filter list, cached per map key. Intune endpoints do not
|
||||
# reliably honor server-side displayName $filter, so name matching is client-side.
|
||||
function Get-GraphMigrationTargetFilters
|
||||
{
|
||||
param([string]$MapKey, [int]$TokenId)
|
||||
|
||||
if(-not $script:_filterListCache) { $script:_filterListCache = @{} }
|
||||
if($script:_filterListCache.ContainsKey($MapKey)) { return $script:_filterListCache[$MapKey] }
|
||||
|
||||
$list = @()
|
||||
$resp = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters?`$select=id,displayName" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
if($resp -and $resp.Value) { $list = @($resp.Value) }
|
||||
$script:_filterListCache[$MapKey] = $list
|
||||
return $list
|
||||
}
|
||||
|
||||
# Main entry point - called from Update-JsonForEnvironment with every GUID and SID
|
||||
# found in the imported policy json. Covers BOTH Entra groups and assignment filters
|
||||
# (the sidecar index tags each entry with its Kind). Returns
|
||||
# @{ IdMap = @{src->target}; SidMap = @{src->target} } containing ONLY references
|
||||
# that need rewriting.
|
||||
function Resolve-GraphMigrationGroups
|
||||
{
|
||||
param($Guids, $Sids, $PolicyObject, [int]$TokenId)
|
||||
|
||||
$result = @{ IdMap = @{}; SidMap = @{} }
|
||||
|
||||
# Same-tenant import: source ids are valid as-is, nothing to translate.
|
||||
#
|
||||
# Get-OperationTokenInfo, not Get-TokenInfo: the import path hands its own TokenId
|
||||
# down, and 0 means "every token" to the list accessor. With two tenants signed in,
|
||||
# [string]$tokenInfo.TenantId was "tenant-a tenant-b", so the same-tenant early
|
||||
# return below never fired - a same-tenant import ran the whole cross-tenant group
|
||||
# translation, and cached its results under a map key naming both tenants.
|
||||
$tokenInfo = Get-OperationTokenInfo $TokenId
|
||||
$targetTenant = if($tokenInfo -and $tokenInfo.TenantId) { [string]$tokenInfo.TenantId } else { [string](Get-CurrentTenantId) }
|
||||
if(-not $targetTenant) { return $result }
|
||||
if($PolicyObject.TenantID -and ([string]$PolicyObject.TenantID) -eq $targetTenant) { return $result }
|
||||
|
||||
$exportRoot = Get-GraphGroupMigrationRoot $PolicyObject
|
||||
if(-not $exportRoot) { return $result }
|
||||
|
||||
$index = Get-GraphGroupSidecarIndex $exportRoot
|
||||
if($index.ById.Count -eq 0) { return $result }
|
||||
|
||||
if(-not $script:_groupMigrationMap) { $script:_groupMigrationMap = @{} }
|
||||
$mapKey = "$exportRoot|$targetTenant"
|
||||
if(-not $script:_groupMigrationMap.ContainsKey($mapKey)) { $script:_groupMigrationMap[$mapKey] = @{} }
|
||||
$map = $script:_groupMigrationMap[$mapKey]
|
||||
|
||||
$createEnabled = (Get-SettingValue "CreateGroupOnImport") -ne $false
|
||||
$convertSynced = (Get-SettingValue "ConvertSyncedGroupOnImport") -ne $false
|
||||
|
||||
# Collect the entries this policy actually references (by id or by SID).
|
||||
$wanted = @{}
|
||||
foreach($guid in @($Guids)) {
|
||||
if($guid -and $index.ById.ContainsKey([string]$guid)) { $wanted[[string]$guid] = $index.ById[[string]$guid] }
|
||||
}
|
||||
foreach($sid in @($Sids)) {
|
||||
if($sid -and $index.BySid.ContainsKey([string]$sid)) {
|
||||
$entry = $index.BySid[[string]$sid]
|
||||
$wanted[$entry.Id] = $entry
|
||||
}
|
||||
}
|
||||
if($wanted.Count -eq 0) { return $result }
|
||||
|
||||
foreach($sourceId in $wanted.Keys)
|
||||
{
|
||||
$entry = $wanted[$sourceId]
|
||||
|
||||
# Cached resolution (positive or negative) from an earlier policy in this run.
|
||||
if($map.ContainsKey($sourceId))
|
||||
{
|
||||
$resolved = $map[$sourceId]
|
||||
}
|
||||
else
|
||||
{
|
||||
$resolved = $null
|
||||
$target = $null
|
||||
|
||||
if($entry.Kind -eq "Filter")
|
||||
{
|
||||
# 1. By id - covers same-guid edge cases and pre-migrated environments.
|
||||
$target = Invoke-MSGraphAPI -Url "deviceManagement/assignmentFilters/$sourceId" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
|
||||
# 2. By display name - client-side match against the cached target list
|
||||
# (Intune endpoints do not reliably honor server-side displayName filters).
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
$wantName = $entry.Name.Trim()
|
||||
$target = Get-GraphMigrationTargetFilters -MapKey $mapKey -TokenId $TokenId |
|
||||
Where-Object { $_.displayName -and $_.displayName.Trim() -eq $wantName } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# 3. Create (same gate as groups - one 'create referenced objects' toggle).
|
||||
if(-not $target)
|
||||
{
|
||||
if(-not $createEnabled)
|
||||
{
|
||||
Write-Log "Assignment filter '$($entry.Name)' ($sourceId) does not exist in the target tenant and CreateGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
else
|
||||
{
|
||||
$target = New-GraphMigrationFilter -Entry $entry -TokenId $TokenId
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
# The create can fail on a duplicate name when an
|
||||
# earlier resolution pass already created the filter
|
||||
# but a transient list failure cached an empty
|
||||
# target list - re-list fresh and match once more.
|
||||
if($script:_filterListCache) { $script:_filterListCache.Remove($mapKey) }
|
||||
$wantName = $entry.Name.Trim()
|
||||
$target = Get-GraphMigrationTargetFilters -MapKey $mapKey -TokenId $TokenId |
|
||||
Where-Object { $_.displayName -and $_.displayName.Trim() -eq $wantName } | Select-Object -First 1
|
||||
}
|
||||
if($target -and $script:_filterListCache -and $script:_filterListCache.ContainsKey($mapKey)) {
|
||||
# Keep the cached target list current for later policies.
|
||||
$script:_filterListCache[$mapKey] = @($script:_filterListCache[$mapKey]) + @($target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
# 1. By id - covers same-guid edge cases and pre-migrated environments.
|
||||
$target = Invoke-MSGraphAPI -Url "/groups/$sourceId" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
|
||||
# 2. By display name.
|
||||
if(-not $target -and $entry.Name)
|
||||
{
|
||||
# Percent-encoded literal: a raw & or # in the name used to split the
|
||||
# query, Graph 400'd, -NoError hid it, and step 3 created a duplicate.
|
||||
$literal = ConvertTo-ODataStringLiteral $entry.Name.Trim()
|
||||
$resp = Invoke-MSGraphAPI -Url "/groups?`$filter=displayName eq $literal&`$select=id,displayName,securityIdentifier" -ODataMetadata "none" -NoError -TokenId $TokenId
|
||||
if($resp -and $resp.Value) { $target = $resp.Value | Select-Object -First 1 }
|
||||
}
|
||||
|
||||
# 3. Create.
|
||||
if(-not $target)
|
||||
{
|
||||
if(-not $createEnabled)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' ($sourceId) does not exist in the target tenant and CreateGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
elseif($entry.Synced -and -not $convertSynced)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' ($sourceId) is an AD-synced group and ConvertSyncedGroupOnImport is disabled - reference not translated" 2
|
||||
}
|
||||
else
|
||||
{
|
||||
if($entry.Synced)
|
||||
{
|
||||
Write-Log "Group '$($entry.Name)' is AD-synced in the source tenant - creating it as a cloud Entra group (ConvertSyncedGroupOnImport)" 2
|
||||
}
|
||||
$target = New-GraphMigrationGroup -Entry $entry -TokenId $TokenId
|
||||
if(-not $target) { Write-Log "Failed to create group '$($entry.Name)' in target tenant" 3 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = if($target) { @{ Id = [string]$target.id; Sid = [string]$target.securityIdentifier } } else { $null }
|
||||
$map[$sourceId] = $resolved
|
||||
}
|
||||
|
||||
if(-not $resolved) { continue }
|
||||
|
||||
if($resolved.Id -and $resolved.Id -ne $sourceId) { $result.IdMap[$sourceId] = $resolved.Id }
|
||||
if($entry.Sid -and $resolved.Sid -and $resolved.Sid -ne $entry.Sid) { $result.SidMap[$entry.Sid] = $resolved.Sid }
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
Reference in New Issue
Block a user