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
}