Public/Copy-EntraUserGroups.ps1
|
function Copy-EntraUserGroups { <# .SYNOPSIS Copies all direct group memberships from one Entra ID user to another. .DESCRIPTION Reads the direct group memberships of the source user and adds the target user to the same groups: - Cloud security groups & Microsoft 365 groups -> Microsoft Graph - Distribution lists & mail-enabled security -> Exchange Online - Dynamic groups -> skipped (rule-based) - Groups synced from on-prem AD -> skipped (manage in AD) - Groups the target is already in -> skipped (idempotent) Admin roles of the source user are listed but never copied. .PARAMETER Source UPN of the user whose memberships are copied. .PARAMETER Target UPN of the user to add. .PARAMETER SkipExchange Skip distribution lists / mail-enabled security groups entirely. .PARAMETER Diff Show a read-only side-by-side membership comparison of both users instead of copying anything. Shorthand for Compare-EntraUserGroups. .EXAMPLE Copy-EntraUserGroups -Source template@contoso.com -Target new@contoso.com -WhatIf .EXAMPLE Copy-EntraUserGroups -Source template@contoso.com -Target new@contoso.com -Diff #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, Position = 0)] [string]$Source, [Parameter(Mandatory, Position = 1)] [string]$Target, [switch]$SkipExchange, [switch]$Diff ) $ErrorActionPreference = 'Stop' if ($Diff) { Compare-EntraUserGroups -Source $Source -Target $Target return } Connect-EgmGraph $srcUser = Get-MgUser -UserId $Source $tgtUser = Get-MgUser -UserId $Target Write-Host "" Write-Host "Source: $($srcUser.DisplayName) <$($srcUser.UserPrincipalName)>" -ForegroundColor Cyan Write-Host "Target: $($tgtUser.DisplayName) <$($tgtUser.UserPrincipalName)>" -ForegroundColor Cyan $srcMemberOf = Get-MgUserMemberOf -UserId $srcUser.Id -All $tgtGroupIds = [System.Collections.Generic.HashSet[string]]::new() Get-MgUserMemberOf -UserId $tgtUser.Id -All | ForEach-Object { [void]$tgtGroupIds.Add($_.Id) } $srcGroups = @($srcMemberOf | ConvertTo-EgmGroupInfo) $srcRoles = @($srcMemberOf | Where-Object { $_.AdditionalProperties['@odata.type'] -eq '#microsoft.graph.directoryRole' }) Write-Host "Direct group memberships of the source user: $($srcGroups.Count)" -ForegroundColor Cyan if ($srcRoles.Count -gt 0) { Write-Host "" Write-Host "Note: $($srcRoles.Count) admin role(s) of the source user are NOT copied:" -ForegroundColor Yellow $srcRoles | ForEach-Object { Write-Host " - $($_.AdditionalProperties['displayName'])" -ForegroundColor Yellow } } $results = [System.Collections.Generic.List[pscustomobject]]::new() $exchangeQueue = [System.Collections.Generic.List[pscustomobject]]::new() function Add-Result([string]$Group, [string]$Type, [string]$Status, [string]$Detail) { $results.Add([pscustomobject]@{ Group = $Group; Type = $Type; Status = $Status; Detail = $Detail }) } foreach ($g in $srcGroups) { if ($tgtGroupIds.Contains($g.Id)) { Add-Result -Group $g.Name -Type $g.Kind -Status 'Skipped' -Detail 'already a member'; continue } if ($g.Route -eq 'SkipDynamic') { Add-Result -Group $g.Name -Type $g.Kind -Status 'Skipped' -Detail 'dynamic group (rule-based membership)'; continue } if ($g.Route -eq 'SkipOnPrem') { Add-Result -Group $g.Name -Type $g.Kind -Status 'Skipped' -Detail 'synced from on-prem AD - manage in local AD'; continue } if ($g.Route -eq 'Exchange') { $exchangeQueue.Add($g); continue } if ($PSCmdlet.ShouldProcess($g.Name, 'Add target user (Graph)')) { try { New-MgGroupMember -GroupId $g.Id -DirectoryObjectId $tgtUser.Id Add-Result -Group $g.Name -Type $g.Kind -Status 'Added' -Detail 'via Graph' } catch { Add-Result -Group $g.Name -Type $g.Kind -Status 'ERROR' -Detail $_.Exception.Message } } else { Add-Result -Group $g.Name -Type $g.Kind -Status 'WhatIf' -Detail 'would be added via Graph' } } if ($exchangeQueue.Count -gt 0) { if ($SkipExchange) { $exchangeQueue | ForEach-Object { Add-Result -Group $_.Name -Type $_.Kind -Status 'Skipped' -Detail '-SkipExchange set' } } elseif ($WhatIfPreference) { $exchangeQueue | ForEach-Object { Add-Result -Group $_.Name -Type $_.Kind -Status 'WhatIf' -Detail 'would be added via Exchange Online' } } else { $exchangeReady = $true try { Connect-EgmExchange } catch { $exchangeReady = $false $reason = $_.Exception.Message $exchangeQueue | ForEach-Object { Add-Result -Group $_.Name -Type $_.Kind -Status 'Skipped' -Detail $reason } } if ($exchangeReady) { foreach ($q in $exchangeQueue) { if ($PSCmdlet.ShouldProcess($q.Name, 'Add target user (Exchange Online)')) { try { Add-DistributionGroupMember -Identity $q.Id -Member $tgtUser.UserPrincipalName -ErrorAction Stop Add-Result -Group $q.Name -Type $q.Kind -Status 'Added' -Detail 'via Exchange Online' } catch { Add-Result -Group $q.Name -Type $q.Kind -Status 'ERROR' -Detail $_.Exception.Message } } } } } } $added = @($results | Where-Object Status -eq 'Added').Count $skipped = @($results | Where-Object Status -eq 'Skipped').Count $whatif = @($results | Where-Object Status -eq 'WhatIf').Count $errors = @($results | Where-Object Status -eq 'ERROR').Count Write-Host "" Write-Host "Added: $added | Skipped: $skipped | WhatIf: $whatif | Errors: $errors" -ForegroundColor $(if ($errors) { 'Red' } else { 'Green' }) $results | Sort-Object Status, Type, Group } |