Public/Compare-EntraUserGroups.ps1

function Compare-EntraUserGroups {
    <#
    .SYNOPSIS
        Read-only side-by-side comparison of the direct group memberships of two Entra ID users.
 
    .DESCRIPTION
        Reads the direct group memberships of both users and returns one record
        per group with Membership = 'Source only', 'Target only' or 'Both'.
        Groups that could not simply be copied (dynamic, on-prem synced,
        Exchange-managed) carry an explanatory note. Nothing is changed.
 
    .PARAMETER Source
        UPN of the first user (the "template" side).
 
    .PARAMETER Target
        UPN of the second user.
 
    .EXAMPLE
        Compare-EntraUserGroups -Source template@contoso.com -Target new@contoso.com
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Source,

        [Parameter(Mandatory, Position = 1)]
        [string]$Target
    )

    $ErrorActionPreference = 'Stop'

    Connect-EgmGraph

    $srcUser = Get-MgUser -UserId $Source
    $tgtUser = Get-MgUser -UserId $Target

    $srcGroups = @(Get-MgUserMemberOf -UserId $srcUser.Id -All | ConvertTo-EgmGroupInfo)
    $tgtGroups = @(Get-MgUserMemberOf -UserId $tgtUser.Id -All | ConvertTo-EgmGroupInfo)

    $srcIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($g in $srcGroups) { [void]$srcIds.Add($g.Id) }
    $tgtIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($g in $tgtGroups) { [void]$tgtIds.Add($g.Id) }

    Write-Host ""
    Write-Host "Source: $($srcUser.UserPrincipalName) | Target: $($tgtUser.UserPrincipalName)" -ForegroundColor Cyan

    function Get-Note([object]$GroupInfo) {
        if ($GroupInfo.IsDynamic) { 'dynamic group - cannot be copied' }
        elseif ($GroupInfo.OnPremSynced) { 'synced from on-prem AD - manage in local AD' }
        elseif ($GroupInfo.Route -eq 'Exchange') { 'managed via Exchange Online' }
        else { '' }
    }

    $records = [System.Collections.Generic.List[pscustomobject]]::new()
    foreach ($g in $srcGroups) {
        $records.Add([pscustomobject]@{
            Group      = $g.Name
            Type       = $g.Kind
            Membership = if ($tgtIds.Contains($g.Id)) { 'Both' } else { 'Source only' }
            Note       = Get-Note -GroupInfo $g
        })
    }
    foreach ($g in $tgtGroups) {
        if (-not $srcIds.Contains($g.Id)) {
            $records.Add([pscustomobject]@{
                Group      = $g.Name
                Type       = $g.Kind
                Membership = 'Target only'
                Note       = Get-Note -GroupInfo $g
            })
        }
    }

    $order = @{ 'Source only' = 0; 'Target only' = 1; 'Both' = 2 }
    $records | Sort-Object { $order[$_.Membership] }, Group
}