EntraRoleMigrator.psm1

function Add-TypeName {
    <#
    .SYNOPSIS
        Helper function that adds a name to the inputobject.
     
    .DESCRIPTION
        Helper function that adds a name to the inputobject.
        Useful to add custom formatting rules to an object.
     
    .PARAMETER Name
        The name to add to the object.
     
    .PARAMETER InputObject
        The object to name.
     
    .EXAMPLE
        PS C:\> Get-ChildItem -File | Add-TypeName -Name 'MyModule.File'
 
        Retrieves all files in the current folder and adds the name "MyModule.File" to each of the result objects.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )
    process {
        if ($null -eq $InputObject) { return }
        $InputObject.PSObject.TypeNames.Insert(0, $Name)
        $InputObject
    }
}

function Assert-ErmConnection
{
<#
    .SYNOPSIS
        Asserts a connection has been established.
     
    .DESCRIPTION
        Asserts a connection has been established.
        Fails the calling command in a terminating exception if not connected yet.
         
    .PARAMETER Service
        The service to which a connection needs to be established.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
        Used to execute the terminating exception in the caller scope if needed.
     
    .EXAMPLE
        PS C:\> Assert-ErmConnection -Service 'Source' -Cmdlet $PSCmdlet
     
        Silently does nothing if already connected to the source tenant.
        Kills the calling command if not yet connected.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Source', 'Destination')]
        [string]
        $Service,
        
        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    
    process
    {
        if (Get-EntraToken -Service "Graph-EntraRoleMigrator-$Service") { return }
        
        $message = "Not connected yet! Use Connect-ErmService to establish a connection to '$Service' first."
        Invoke-TerminatingException -Cmdlet $Cmdlet -Message $message -Category ConnectionError
    }
}

function ConvertTo-RoleMembership {
    <#
    .SYNOPSIS
        Converts the various API results into full, uniform role membership assignments data.
     
    .DESCRIPTION
        Converts the various API results into full, uniform role membership assignments data.
     
    .PARAMETER Roles
        A hashtable used to cache role data.
        Otherwise, we might repeatedly resolve the same role object.
     
    .PARAMETER Tenant
        What tenant to resolve roles from.
     
    .PARAMETER Path
        What API path was queries.
        Some resolution steps are specific to the path the objects are received from.
     
    .PARAMETER Assigned
        A hashtable to cache those principals, that have been actively assigned by assignment schedule request.
        This is used to later not report the same result from the active direct role assignments, as that apie ALSO reports
        assignments from the PIM assignment APIs.
     
    .PARAMETER InputObject
        The API-response object(s) to convert.
     
    .EXAMPLE
        PS C:\> Invoke-EntraRequest -Service $service -Path $endpointPath -ErrorAction Stop -Query $query | ConvertTo-RoleMembership -Roles $roleCache -Tenant $Tenant -Path $endpointPath -Assigned $assignCache
     
        Converts all the response objects into proper role membership objects.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $Roles,

        [Parameter(Mandatory = $true)]
        [string]
        $Tenant,

        [Parameter(Mandatory = $true)]
        [string]
        $Path,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Assigned,

        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )

    begin {
        $assignmentType = 'Permanent'
        if ($Path -eq 'roleManagement/directory/roleEligibilityScheduleRequests') {
            $assignmentType = 'Eligible'
        }

        $results = [System.Collections.ArrayList]@()
        $allItems = [System.Collections.ArrayList]@()
    }
    process {
        if (-not $InputObject) { return }
        $null = $allItems.Add($InputObject)

        if (-not $Roles[$InputObject.roleDefinitionId]) {
            $Roles[$InputObject.roleDefinitionId] = Get-ErmRole -Id $InputObject.roleDefinitionId -Tenant $Tenant
        }

        $isScheduleRequest = $InputObject.PSObject.Properties.Name -contains 'scheduleInfo'
        if ($isScheduleRequest -and $InputObject.status -ne 'Provisioned') { return }

        if ($Path -eq 'roleManagement/directory/roleAssignmentScheduleRequests') {
            if (-not $InputObject.scheduleInfo.expiration.endDateTime) {
                $assignmentType = 'PermAssigned'
            }
            else {
                $assignmentType = 'TempAssigned'
            }
        }
        if ($assignmentType -eq 'PermAssigned') {
            $Assigned["$($InputObject.roleDefinitionId)|$($InputObject.principalId)"] = $true
        }
        if ($assignmentType -eq 'Permanent' -and $Assigned["$($InputObject.roleDefinitionId)|$($InputObject.principalId)"]) {
            return
        }
        # Only schedule requests that are "AdminAssign" are actual eligibility assignments
        if ($assignmentType -eq 'Eligible' -and $InputObject.action -ne 'AdminAssign') {
            return
        }

        $entry = [PSCustomObject]@{
            PSTypeName         = 'EntraRoleMigrator.RoleMember'
            RoleID             = $InputObject.roleDefinitionId
            RoleName           = $Roles[$InputObject.roleDefinitionId].DisplayName
            AssignmentType     = $assignmentType
            PrincipalID        = $InputObject.principalId
            PrincipalType      = $InputObject.principal.'@odata.type' -replace '^.+\.'
            Principal          = $InputObject.principal.displayName
            DirectoryScopeId   = $InputObject.directoryScopeId
            AccountEnabled     = $InputObject.principal.AccountEnabled

            AssgignmentID      = $InputObject.id

            # Eligibility Configuration
            ScheduleStart      = $InputObject.scheduleInfo.startDateTime
            ScheduleEnd        = $InputObject.scheduleInfo.expiration.endDateTime
            ScheduleDuration   = $InputObject.scheduleInfo.expiration.duration
            EligibleAction     = $InputObject.action
            EligibleMemberType = $InputObject.memberType
            AppScopeId         = $InputObject.appScopeId

            RoleObject         = $Roles[$InputObject.roleDefinitionId]
            PrincipalObject    = $InputObject.principal
            AssignmentObject   = $InputObject
        }
        $null = $results.Add($entry)
    }
    end {
        # All this only because the API will include open PIM-API requests for 30 days after they stop mattering ...
        $revocations = $allItems | Where-Object action -eq adminRemove

        foreach ($item in $results) {
            # Permanent Assignments are just fine
            if ($item.AssignmentType -eq 'Permanent') {
                $item
                continue
            }

            # Was this request revoked at a later time?
            if (
                $revocations | Where-Object {
                    $_.roleDefinitionId -eq $item.RoleID -and
                    $_.principalId -eq $item.PrincipalID -and
                    $_.appScopeId -eq $item.AppScopeId -and
                    $_.directoryScopeId -eq $item.DirectoryScopeId -and
                    $_.createdDateTime -ge $item.AssignmentObject.createdDateTime
                }
            ) { continue }

            $item
        }
    }
}

function Get-Identity {
    <#
    .SYNOPSIS
        Tries to resolve principals based on the filter criteria provided.
     
    .DESCRIPTION
        Tries to resolve principals based on the filter criteria provided.
        Helper function that is part of making the Identity Mapping feature work.
     
    .PARAMETER Tenant
        The tenant to search the principal for.
        Should generally be "Destination"
     
    .PARAMETER Type
        The type of principal we are trying to find.
     
    .PARAMETER Property
        The name of the property to filter by.
     
    .PARAMETER Value
        The value the property should have on that principal.
     
    .EXAMPLE
        PS C:\> Get-Identity -Tenant Destination -Type user -Property userPrincipalName -Value fred@fabrikam.org
 
        Looks in the destination tenant for the user with a UPN named "fred@fabrikam.org"
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Source', 'Destination')]
        [string]
        $Tenant,

        [Parameter(Mandatory = $true)]
        [EntraRoleMigrator.PrincipalType]
        $Type,

        [Parameter(Mandatory = $true)]
        [string]
        $Property,

        [Parameter(Mandatory = $true)]
        $Value
    )
    begin {
        Assert-ErmConnection -Service $Tenant -Cmdlet $PSCmdlet
        $service = "Graph-EntraRoleMigrator-$Tenant"
    }
    process {
        switch ("$Type") {
            'user' {
                $result = Invoke-EntraRequest -Service $service -Path 'users' -Query @{
                    '$filter' = ('{0} eq ''{1}''' -f $Property, $Value) -replace '#','%23'
                    '$select' = 'id,userPrincipalName'
                }
                if (-not $result) { return }
                if (@($result).Count -gt 1) {
                    Write-PSFMessage -Level Error -Message 'Error resolving user in {0} for property {1} and value {2} - Multiple matches detected! Non-unique identities will not be matched for role memberships.' -StringValues $Tenant, $Property, $Value -Tag Duplicate -Data @{ EventID = 400 }
                    foreach ($entry in $result) {
                        Write-PSFMessage -Level Warning -Message ' User {0} (ID: {1})' -StringValues $entry.userPrincipalName, $entry.ID
                    }
                    [PSCustomObject]@{
                        Id     = $result.id
                        Name   = $result.userPrincipalName
                        Type   = 'User'
                        Result = 'Multiple'
                    }
                    return
                }

                [PSCustomObject]@{
                    Id     = $result.id
                    Name   = $result.userPrincipalName
                    Type   = 'User'
                    Result = 'Single'
                }
            }
            'servicePrincipal' {
                $result = Invoke-EntraRequest -Service $service -Path 'applications' -Query @{
                    '$filter' = ('{0} eq ''{1}''' -f $Property, $Value) -replace '#','%23'
                    '$select' = 'id,displayName'
                }
                if (-not $result) { return }

                if (@($result).Count -gt 1) {
                    Write-PSFMessage -Level Error -Message 'Error resolving ServicePrincipal in {0} for property {1} and value {2} - Multiple matches detected! Non-unique identities will not be matched for role memberships.' -StringValues $Tenant, $Property, $Value -Tag Duplicate -Data @{ EventID = 401 }
                    foreach ($entry in $result) {
                        Write-PSFMessage -Level Warning -Message ' ServicePrincipal {0} (ID: {1})' -StringValues $entry.displayName, $entry.ID
                    }
                    [PSCustomObject]@{
                        Id     = $result.id
                        Name   = $result.displayName
                        Type   = 'servicePrincipal'
                        Result = 'Multiple'
                    }
                    return
                }

                [PSCustomObject]@{
                    Id     = $result.Id
                    Name   = $result.displayName
                    Type   = 'servicePrincipal'
                    Result = 'Single'
                }
            }
            default {
                throw "Identity type $Type Not Implemented Yet!"
            }
        }
    }
}

function New-Change {
    <#
    .SYNOPSIS
        Helper function generating new change objects.
     
    .DESCRIPTION
        Helper function generating new change objects.
     
    .PARAMETER Action
        The action performed
     
    .PARAMETER Property
        The name of the property affected.
     
    .PARAMETER Value
        The value the property should have.
     
    .PARAMETER Name
        The name of the role.
     
    .PARAMETER ID
        The id of the role.
     
    .EXAMPLE
        PS C:\> New-Change -Action Update -Property DirectoryScopeId -Value $sourceAssignment.DirectoryScopeId -Name $matchingDest.RoleName -ID $matchingDest.RoleID
 
        Generates a new change based on the input provided.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Action,

        [string]
        $Property,

        $Value,

        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        [string]
        $ID
    )
    process {
        [PSCustomObject]@{
            PSTypeName = 'EntraRoleMigrator.Change'
            Action     = $Action
            Property   = $Property
            Value      = $Value
            Name       = $Name
            ID         = $ID
        }
    }
}

function New-TestResult {
    <#
    .SYNOPSIS
        Helper command, generates a unified test result object.
     
    .DESCRIPTION
        Helper command, generates a unified test result object.
        Used in Test-Erm* commands to report on the actions that need be taken.
        These objects will be used in their respective Invoke-Erm* commands to apply those changes.
     
    .PARAMETER Category
        The category of change needed.
     
    .PARAMETER Action
        The action performed against the object.
     
    .PARAMETER Identity
        The object being modified.
     
    .PARAMETER SourceObject
        The object from the source tenant.
        May be omitted in case of delete actions where no matching object can be found in the source tenant.
     
    .PARAMETER DestinationObject
        The object from the destination tenant.
        May be omitted in case of create actions, where no matching object can yet be found in the destination tenant.
     
    .PARAMETER Change
        Objects representing the actual change to perform.
     
    .EXAMPLE
        PS C:\> New-TestResult -Category Role -Action Update -Identity $destRole.displayName -SourceObject $sourceRole -DestinationObject $destRole -Change $changes
 
        Generates a test result, describing an update action against a role.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Role', 'Membership')]
        [string]
        $Category,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Create', 'Update', 'Delete', 'Add', 'Remove', 'Ignore')]
        [string]
        $Action,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $Identity,

        [AllowNull()]
        $SourceObject,

        [AllowNull()]
        $DestinationObject,

        [AllowEmptyCollection()]
        $Change
    )
    process {
        $obj = [PSCustomObject]@{
            PSTypeName  = 'EntraRoleMigrator.TestResult'
            Category    = $Category
            Action      = $Action
            Identity    = $Identity
            Source      = $SourceObject
            Destination = $DestinationObject
            Change      = $Change
        }
        [PSFramework.Object.ObjectHost]::AddScriptProperty($obj, 'ChangeDisplay', $script:_ResultDisplayStyles["$($Category)-$($Action)"])
        [PSFramework.Object.ObjectHost]::AddScriptMethod($obj, 'ToString', { $this.Identity })
        $obj
    }
}

function Resolve-DestinationIdentity {
    <#
    .SYNOPSIS
        Resolves an identity in the destination tenant.
     
    .DESCRIPTION
        Resolves an identity in the destination tenant.
        Centerpiece implementing the Identity Mapping component.
     
    .PARAMETER InputObject
        An assignment object to extend with target identity information.
        Would usually be a role membership object from the source tenant.
     
    .EXAMPLE
        PS C:\> $sourceAssignments | Resolve-DestinationIdentity
 
        Extends the role assignment information from the source tenant to matching identities in the destination tenant.
    #>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )

    begin {
        $identityCache = @{ }
        # Prepare for mapping to target tenant
        $targetRoleCache = @{ }
        foreach ($role in Get-ErmRole -Tenant Destination) {
            $targetRoleCache[$role.displayName] = $role
        }
        $supportedTypes = [enum]::GetNames([EntraRoleMigrator.PrincipalType])
    }
    process {
        foreach ($assignment in $InputObject) {
            if ($identityCache[$assignment.PrincipalID]) {
                [PSFramework.Object.ObjectHost]::AddNoteProperty(
                    $assignment,
                    @{
                        TargetID     = $identityCache[$assignment.PrincipalID].Id
                        TargetName   = $identityCache[$assignment.PrincipalID].Name
                        TargetType   = $identityCache[$assignment.PrincipalID].Type
                        TargetResult = $identityCache[$assignment.PrincipalID].Result
                        TargetRoleID = $targetRoleCache[$assignment.RoleName].id
                    }
                )
                $assignment
                continue
            }

            if ($assignment.PrincipalType -notin $supportedTypes) {
                $identityCache[$assignment.PrincipalID] = [PSCustomObject]@{
                    Id     = $null
                    Name   = $null
                    Type   = $assignment.PrincipalType
                    Result = 'Unsupported'
                }
                [PSFramework.Object.ObjectHost]::AddNoteProperty(
                    $assignment,
                    @{
                        TargetID     = $identityCache[$assignment.PrincipalID].Id
                        TargetName   = $identityCache[$assignment.PrincipalID].Name
                        TargetType   = $identityCache[$assignment.PrincipalID].Type
                        TargetResult = $identityCache[$assignment.PrincipalID].Result
                        TargetRoleID = $targetRoleCache[$assignment.RoleName].id
                    }
                )
                $assignment
                continue
            }

            $identityMap = Resolve-ErmIdentityMap -Principal $assignment.PrincipalObject -Type $assignment.PrincipalType
            $identityResult = foreach ($identityOption in $identityMap | Sort-Object Priority) {
                if (-not $identityOption.Value) { continue }
                $identity = Get-Identity -Tenant Destination -Type $identityOption.Type -Property $identityOption.Property -Value $identityOption.Value
                if ($identity) {
                    $identity
                    break
                }
            }

            if ($identityResult) { $identityCache[$assignment.PrincipalID] = $identityResult }
            else {
                $identityCache[$assignment.PrincipalID] = [PSCustomObject]@{
                    Id     = $null
                    Name   = $null
                    Type   = $assignment.PrincipalType
                    Result = 'Unresolved'
                }
            }

            [PSFramework.Object.ObjectHost]::AddNoteProperty(
                $assignment,
                @{
                    TargetID     = $identityCache[$assignment.PrincipalID].Id
                    TargetName   = $identityCache[$assignment.PrincipalID].Name
                    TargetType   = $identityCache[$assignment.PrincipalID].Type
                    TargetResult = $identityCache[$assignment.PrincipalID].Result
                    TargetRoleID = $targetRoleCache[$assignment.RoleName].id
                }
            )
            $assignment
        }
    }
}

function Connect-ErmService {
    <#
    .SYNOPSIS
        Establish a connection to the graph API.
     
    .DESCRIPTION
        Establish a connection to the graph API.
        Prerequisite before executing any requests / commands.
     
    .PARAMETER ClientID
        ID of the registered/enterprise application used for authentication.
     
    .PARAMETER TenantID
        The ID of the tenant/directory to connect to.
     
    .PARAMETER Scopes
        Any scopes to include in the request.
        Only used for interactive/delegate workflows, ignored for Certificate based authentication or when using Client Secrets.
 
    .PARAMETER Browser
        Use an interactive logon in your default browser.
        This is the default logon experience.
 
    .PARAMETER BrowserMode
        How the browser used for authentication is selected.
        Options:
        + Auto (default): Automatically use the default browser.
        + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine)
 
    .PARAMETER DeviceCode
        Use the Device Code delegate authentication flow.
        This will prompt the user to complete login via browser.
     
    .PARAMETER Certificate
        The Certificate object used to authenticate with.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificateThumbprint
        Thumbprint of the certificate to authenticate with.
        The certificate must be stored either in the user or computer certificate store.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificateName
        The name/subject of the certificate to authenticate with.
        The certificate must be stored either in the user or computer certificate store.
        The newest certificate with a private key will be chosen.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificatePath
        Path to a PFX file containing the certificate to authenticate with.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER CertificatePassword
        Password to use to read a PFX certificate file.
        Only used together with -CertificatePath.
         
        Part of the Application Certificate authentication workflow.
     
    .PARAMETER ClientSecret
        The client secret configured in the registered/enterprise application.
         
        Part of the Client Secret Certificate authentication workflow.
 
    .PARAMETER Credential
        The username / password to authenticate with.
 
        Part of the Resource Owner Password Credential (ROPC) workflow.
 
    .PARAMETER VaultName
        Name of the Azure Key Vault from which to retrieve the certificate or client secret used for the authentication.
        Secrets retrieved from the vault are not cached, on token expiration they will be retrieved from the Vault again.
        In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection,
        or are connected via Connect-AzAccount.
 
    .PARAMETER SecretName
        Name of the secret to use from the Azure Key Vault specified through the '-VaultName' parameter.
        In order for this flow to work, please ensure that you either have an active AzureKeyVault service connection,
        or are connected via Connect-AzAccount.
 
    .PARAMETER Identity
        Log on as the Managed Identity of the current system.
        Only works in environments with managed identities, such as Azure Function Apps or Runbooks.
 
    .PARAMETER IdentityID
        ID of the User-Managed Identity to connect as.
        https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity
 
    .PARAMETER IdentityType
        Type of the User-Managed Identity.
 
    .PARAMETER ServiceUrl
        The base url to the service connecting to.
        Used for authentication, scopes and executing requests.
        Defaults to: https://graph.microsoft.com/v1.0
 
    .PARAMETER Type
        The type of tenant to connect to.
        Either "Source" or "Destination".
     
    .EXAMPLE
        PS C:\> Connect-ErmService -Type Source -ClientID $clientID -TenantID $tenantID
     
        Establish a connection to the source tenant, prompting the user for login on their default browser.
     
    .EXAMPLE
        PS C:\> Connect-ErmService -Type Source -ClientID $clientID -TenantID $tenantID -Certificate $cert
     
        Establish a connection to the source tenant using the provided certificate.
     
    .EXAMPLE
        PS C:\> Connect-ErmService -Type Source -ClientID $clientID -TenantID $tenantID -CertificatePath C:\secrets\certs\mde.pfx -CertificatePassword (Read-Host -AsSecureString)
     
        Establish a connection to the source tenant using the provided certificate file.
        Prompts you to enter the certificate-file's password first.
     
    .EXAMPLE
        PS C:\> Connect-ErmService -Type Source -ClientID $clientID -TenantID $tenantID -ClientSecret $secret
     
        Establish a connection to the source tenant using a client secret.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseDeclaredVarsMoreThanAssignments", "")]
    [CmdletBinding(DefaultParameterSetName = 'Browser')]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Source', 'Destination')]
        [string]
        $Type,

        [Parameter(Mandatory = $true, ParameterSetName = 'Browser')]
        [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $ClientID,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Browser')]
        [Parameter(Mandatory = $true, ParameterSetName = 'DeviceCode')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppCertificate')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $TenantID,
        
        [Parameter(ParameterSetName = 'Browser')]
        [Parameter(ParameterSetName = 'DeviceCode')]
        [Parameter(ParameterSetName = 'AppCertificate')]
        [Parameter(ParameterSetName = 'AppSecret')]
        [Parameter(ParameterSetName = 'UsernamePassword')]
        [string[]]
        $Scopes,

        [Parameter(ParameterSetName = 'Browser')]
        [switch]
        $Browser,

        [Parameter(ParameterSetName = 'Browser')]
        [ValidateSet('Auto','PrintLink')]
        [string]
        $BrowserMode = 'Auto',

        [Parameter(ParameterSetName = 'DeviceCode')]
        [switch]
        $DeviceCode,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]
        $Certificate,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateThumbprint,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificateName,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [string]
        $CertificatePath,
        
        [Parameter(ParameterSetName = 'AppCertificate')]
        [System.Security.SecureString]
        $CertificatePassword,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'AppSecret')]
        [System.Security.SecureString]
        $ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'UsernamePassword')]
        [PSCredential]
        $Credential,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $VaultName,

        [Parameter(Mandatory = $true, ParameterSetName = 'KeyVault')]
        [string]
        $SecretName,

        [Parameter(Mandatory = $true, ParameterSetName = 'Identity')]
        [switch]
        $Identity,

        [Parameter(ParameterSetName = 'Identity')]
        [string]
        $IdentityID,

        [Parameter(ParameterSetName = 'Identity')]
        [ValidateSet('ClientID', 'ResourceID', 'PrincipalID')]
        [string]
        $IdentityType = 'ClientID',

        [string]
        $ServiceUrl
    )
    
    process {
        $actualServiceNames = "Graph-EntraRoleMigrator-$Type"

        $param = $PSBoundParameters | ConvertTo-PSFHashtable -ReferenceCommand Connect-EntraService
        $param.Service = $actualServiceNames
        Connect-EntraService @param
    }
}

function Get-ErmIdentityMapping {
    <#
    .SYNOPSIS
        Lists any registered identity translation/mapping logic.
     
    .DESCRIPTION
        Lists any registered identity translation/mapping logic.
        Those are used to match identities across tenants.
 
        Use Register-ErmIdentityMapiing to provide new mapping logic.
     
    .PARAMETER Type
        Filter by the principal type the mapping is for.
        Defaults to: *
     
    .PARAMETER Name
        Filter by the name assigned to the mapping.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-ErmIdentityMapping
 
        Lists all registered Identity Mappings.
    #>

    [CmdletBinding()]
    param (
        [PsfArgumentCompleter('EntraRoleMigrator.IdentityMapping.Type')]
        [string]
        $Type = '*',

        [PsfArgumentCompleter('EntraRoleMigrator.IdentityMapping.Name')]
        [string]
        $Name = '*'
    )
    process {
        ($script:_IdentityMapping.Values.Values) | Where-Object {
            $_.Type -like $Type -and
            $_.Name -like $Name
        }
    }
}

function Register-ErmIdentityMapping {
    <#
    .SYNOPSIS
        Registers a new identity translation logic.
     
    .DESCRIPTION
        Registers a new identity translation logic.
        These are used to match principals from the source tenant to those in the destination tenants.
        They can only take a single property from the source identity and try to convert it to the expected value in the destination tenant.
 
        This allows matching users or service principals based on attributes.
        For example, a user with a UPN of "<username>@contoso.com" in the source tenant, could be matched to a user with the UPN of "<username>@fabrikam.org" in the destination tenant.
 
        Multiple mapping methods can be provided for a single object type - they will be processed in the order of priority,
        with lower numbers taking precedence over higher numbers.
     
    .PARAMETER Type
        The type of object the mapping is for.
        Supports "User" or "ServicePrincipal"
     
    .PARAMETER Name
        The name to assign to the mapping.
        Has no technical impact, but allows overriding / removing mappings later on.
     
    .PARAMETER Priority
        The priority of the mapping.
        The lower the number, the earlier it is used when translating identities.
        For any given identity, the first mapping that finds a result in the destination tenant wins.
     
    .PARAMETER SourceProperty
        The property on the principal object from the source tenant to use.
     
    .PARAMETER DestinationProperty
        The property on the principal object from the destination tenant, that will be expected to have the value
        the conversion logic generates from the value on the source property of the source object.
     
    .PARAMETER Conversion
        The scriptblock used to take the value of the source property, to calculate the value expected on the destination object.
        This scriptblock will receive the input value from the source object as "$_"
     
    .EXAMPLE
        PS C:\> Register-ErmIdentityMapping -Type user -Name default -Priority 100 -SourceProperty userPrincipalName -DestinationProperty userPrincipalName -Conversion { $_ -replace 'contoso.com','fabrikam.org' }
 
        This will provide a simple translation based on UPN of users.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [EntraRoleMigrator.PrincipalType]
        $Type,

        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [int]
        $Priority = 50,

        [Parameter(Mandatory = $true)]
        [string]
        $SourceProperty,

        [Parameter(Mandatory = $true)]
        [string]
        $DestinationProperty,

        [Parameter(Mandatory = $true)]
        [PsfScriptblock]
        $Conversion
    )
    process {
        if (-not $script:_IdentityMapping["$Type"]) {
            $script:_IdentityMapping["$Type"] = @{ }
        }

        $script:_IdentityMapping["$Type"][$Name] = [PSCustomObject]@{
            PSTypeName          = 'EntraRoleMigrator.IdentityMapping'
            Name                = $Name
            Type                = "$Type"
            Priority            = $Priority
            SourceProperty      = $SourceProperty
            DestinationProperty = $DestinationProperty
            Conversion          = $Conversion
        }
    }
}

function Resolve-ErmIdentityMap {
    <#
    .SYNOPSIS
        Calculates the values to search for in the dextination tenant, based on a principal from the source tenant.
     
    .DESCRIPTION
        Calculates the values to search for in the dextination tenant, based on a principal from the source tenant.
        This generates the search values from the principal from the source tenant, that will later be used to figure out
        the matching principal in the destination tenant.
 
        Used to make sure role memberships are properly translated.
     
    .PARAMETER Principal
        The principal object from the source tenant to translate.
     
    .PARAMETER Type
        What kind of principal type to translate.
     
    .EXAMPLE
        PS C:\> Resolve-ErmIdentityMap -Principal $assignment.principal -Type user
 
        Generates the possible, expected values to look for the user on an assignment.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        $Principal,

        [Parameter(Mandatory = $true)]
        [EntraRoleMigrator.PrincipalType]
        $Type
    )
    process {
        foreach ($mapping in $script:_IdentityMapping."$Type".Values | Sort-Object Priority) {
            try {
                $result = $mapping.Conversion.InvokeGlobal($Principal.$($mapping.SourceProperty))
                [PSCustomObject]@{
                    Type     = $Type
                    Priority = $mapping.Priority
                    Value    = $($result)
                    Property = $mapping.DestinationProperty
                }
            }
            catch {
                Write-PSFMessage -Level Warning -Message 'Failed to process mapping {0} for type {1} on principal {2}' -StringValues $mapping.Name, $mapping.Type, $Principal.Id -ErrorRecord $_
            }
        }
    }
}

function Unregister-ErmIdentityMapping {
    <#
    .SYNOPSIS
        Removes a piece of identity mapping logic.
     
    .DESCRIPTION
        Removes a piece of identity mapping logic.
        Those are used to translate principal identities from the source tenant to the destination tenant.
 
        Use Register-ErmIdentityMapping to define new mappings.
        Use Get-ErmIdentityMapping to get a list of the currently provided mappings.
     
    .PARAMETER Type
        The type the mapping is for.
     
    .PARAMETER Name
        The name of the mapping to remove.
     
    .EXAMPLE
        PS C:\> Unregister-ErmIdentityMapping -Type user -Name MyTestMapping
         
        Removes the "MyTestMapping" that would translate identities for user objects.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PsfArgumentCompleter('EntraRoleMigrator.IdentityMapping.Type')]
        [string]
        $Type,

        [Parameter(Mandatory = $true)]
        [PsfArgumentCompleter('EntraRoleMigrator.IdentityMapping.Name')]
        [string]
        $Name
    )
    process {
        if (-not $script:_IdentityMapping[$Type]) {
            return
        }

        $script:_IdentityMapping[$Type].Remove($Name)
    }
}

function Get-ErmRole {
    <#
    .SYNOPSIS
        Read the roles available from the target tenant.
     
    .DESCRIPTION
        Read the roles available from the target tenant.
        Use "Connect-ErmService" first before using this command.
     
    .PARAMETER Tenant
        Whether to read from the source or destination tenant.
     
    .PARAMETER Id
        ID or displayname of the role to retrieve.
     
    .EXAMPLE
        PS C:\> Get-ErmRole -Tenant Source
         
        List all roles in the source tenant
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Source', 'Destination')]
        [string]
        $Tenant,

        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Id
    )
    begin {
        $service = "Graph-EntraRoleMigrator-$Tenant"
        Assert-ErmConnection -Service $Tenant -Cmdlet $PSCmdlet
    }
    process {
        if (-not $Id) {
            Invoke-EntraRequest -Service $service -Path 'roleManagement/directory/roleDefinitions' | Add-TypeName -Name 'EntraRoleMigrator.Role'
            return
        }

        foreach ($idEntry in $Id) {
            if ($idEntry -as [guid]) {
                Invoke-EntraRequest -Service $service -Path "roleManagement/directory/roleDefinitions/$idEntry" | Add-TypeName -Name 'EntraRoleMigrator.Role'
            }
            else {
                Invoke-EntraRequest -Service $service -Path "roleManagement/directory/roleDefinitions" -Query @{
                    '$filter' = "displayName eq '$idEntry'"
                } | Add-TypeName -Name 'EntraRoleMigrator.Role'
            }
        }
    }
}

function Invoke-ErmRole {
    <#
    .SYNOPSIS
        Remediates all differences between source and destination role configurations.
     
    .DESCRIPTION
        Remediates all differences between source and destination role configurations.
        This command will either run a full comparison between all custom roles or only apply the results provided to it from previous test runs.
     
    .PARAMETER TestResult
        Results to execute.
        Use Test-ErmRole to get a list of differences to remediate.
        This parameter allows cherrypicking, what differences to apply.
        By default, this command will run a full scan and remediate all deltas.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
 
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
 
    .EXAMPLE
        PS C:\> Invoke-ErmRole
         
        Remediates all differences between source and destination role configurations.
 
    .EXAMPLE
        PS C:\> Invokre-ErmRole -TestResult $results[0]
 
        Only applies a single test result from a previous Test-ErmRole execution.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $TestResult,

        [switch]
        $EnableException
    )

    begin {
        $serviceDestination = "Graph-EntraRoleMigrator-Destination"
        Assert-ErmConnection -Service Source -Cmdlet $PSCmdlet
        Assert-ErmConnection -Service Destination -Cmdlet $PSCmdlet
    }
    process {
        $testObjects = $TestResult
        if (-not $testObjects) {
            $testObjects = Test-ErmRole
        }

        foreach ($testItem in $testObjects) {
            #region Validation
            if ($testItem.PSObject.TypeNames -notcontains 'EntraRoleMigrator.TestResult') {
                Write-PSFMessage -Level Warning -Message 'Not a EntraRoleMigrator test result! {0}. Use Test-ErmRole to generate valid test results.' -StringValues $testItem -Target $testItem
                Write-Error "Not a EntraRoleMigrator test result! $testItem"
                continue
            }
            if ($testItem.Category -ne 'Role') {
                Write-PSFMessage -Level Warning -Message 'Not a Role test result! Use Test-ErmRole to generate valid test results. Input: {0} -> {1}: {2}' -StringValues $testItem.Category, $testItem.Action, $testItem.Identity -Target $testItem
                Write-Error "Not a Role test result: $($testItem.Category) -> $($testItem.Action): $($testItem.Identity)"
                continue
            }
            #endregion Validation

            switch ($testItem.Action) {
                #region Create
                'Create' {
                    $body = @{
                        description = $testItem.Source.description
                        displayName = $testItem.Source.displayName
                        rolePermissions = @(
                            @{
                                allowedResourceActions = @(
                                    $testItem.Source.rolePermissions.allowedResourceActions
                                )
                            }
                        )
                        isEnabled = $testItem.Source.isEnabled
                    }

                    Invoke-PSFProtectedCommand -Action "Creating Custom Role $($testItem.Source.displayName)" -Target $testItem -ScriptBlock {
                        $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path 'roleManagement/directory/roleDefinitions' -Body $body
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Create

                #region Delete
                'Delete' {
                    Invoke-PSFProtectedCommand -Action "Deleting Custom Role $($testItem.Destination.displayName)" -Target $testItem -ScriptBlock {
                        $null = Invoke-EntraRequest -Service $serviceDestination -Method Delete -Path "roleManagement/directory/roleDefinitions/$($testItem.Destination.Id)"
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Delete

                #region Update
                'Update' {
                    $body = @{ }
                    foreach ($change in $testItem.Change) {
                        switch ($change.Action) {
                            "Update" {
                                Write-PSFMessage -Message 'Planning Role Update - {0} ({1}): Setting {2} to {3}' -StringValues $testItem.Destination.displayName, $testItem.Destination.id, $change.Property, $change.Value
                                $body[$change.Property] = $change.Value
                            }
                            'AddRight' {
                                Write-PSFMessage -Message 'Planning Role Update - {0} ({1}): Adding right "{2}"' -StringValues $testItem.Destination.displayName, $testItem.Destination.id, $change.Value
                                $body['rolePermissions'] = @(
                                    @{
                                        allowedResourceActions = @(
                                            $testItem.Source.rolePermissions.allowedResourceActions
                                        )
                                    }
                                )
                            }
                            'RemoveRight' {
                                Write-PSFMessage -Message 'Planning Role Update - {0} ({1}): Removing right "{2}"' -StringValues $testItem.Destination.displayName, $testItem.Destination.id, $change.Value
                                $body['rolePermissions'] = @(
                                    @{
                                        allowedResourceActions = @(
                                            $testItem.Source.rolePermissions.allowedResourceActions
                                        )
                                    }
                                )
                            }
                        }
                    }
                    Invoke-PSFProtectedCommand -Action "Updating Custom Role $($testItem.Destination.displayName)" -Target $testItem -ScriptBlock {
                        $null = Invoke-EntraRequest -Service $serviceDestination -Method Patch -Path "roleManagement/directory/roleDefinitions/$($testItem.Destination.Id)" -Body $body
                    } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                }
                #endregion Update
            }
        }
    }
}

function Test-ErmRole {
    <#
    .SYNOPSIS
        Tests, whether the destination tenant's role configuration differs from the source.
     
    .DESCRIPTION
        Tests, whether the destination tenant's role configuration differs from the source.
        Will return one entry per role deviation.
 
        Use Connect-ErmService twice first, to connect to both source and destination tenant.
     
    .EXAMPLE
        PS C:\> Test-ErmRole
         
        Tests, whether the destination tenant's role configuration differs from the source.
    #>

    [CmdletBinding()]
    param (
        
    )
    begin {
        Assert-ErmConnection -Service Source -Cmdlet $PSCmdlet
        Assert-ErmConnection -Service Destination -Cmdlet $PSCmdlet

        #region Utility Functions
        function Test-RoleDeletion {
            [CmdletBinding()]
            param (
                $Source,

                $Destination
            )

            foreach ($undesired in $Destination | Where-Object displayName -NotIn $Source.displayName) {
                if ($null -eq $undesired) { continue }
                New-TestResult -Category Role -Action Delete -Identity $undesired.DisplayName -DestinationObject $undesired -Change @(
                    New-Change -Action Delete -Value $undesired -Name $undesired.displayName -ID $undesired.id
                )
            }
        }
        function Test-RoleCreation {
            [CmdletBinding()]
            param (
                $Source,
                $Destination
            )

            foreach ($intended in $Source | Where-Object displayName -NotIn $Destination.displayName) {
                if ($null -eq $intended) { continue }
                New-TestResult -Category Role -Action Create -Identity $intended.DisplayName -SourceObject $intended -Change @(
                    New-Change -Action Create -Value $intended -Name $intended.displayName -ID $intended.id
                )
            }
        }
        function Test-RoleUpdate {
            [CmdletBinding()]
            param (
                $Source,
                $Destination
            )

            <#
            Notes:
            https://learn.microsoft.com/en-us/graph/api/resources/unifiedroledefinition?view=graph-rest-1.0#properties
            - resourceScopes is being deprecated in favor of them being tied to assignments.
            - rolePermissions/condition is for builtin roles only
            - rolePermissions/excludedResourceActions is not implemented yet
            #>


            foreach ($destRole in $Destination) {
                $sourceRole = $Source | Where-Object displayName -EQ $destRole.displayName
                # Create / Delete are handled outside of this function
                if (-not $sourceRole) { continue }

                $changes = @()
    
                # Description
                if ($sourceRole.Description -ne $destRole.Description) {
                    $changes += New-Change -Action Update -Property description -Value $sourceRole.Description -Name $destRole.displayName -ID $destRole.id
                }

                # isEnabled
                if ($sourceRole.isEnabled -ne $destRole.isEnabled) {
                    $changes += New-Change -Action Update -Property isEnabled -Value $sourceRole.isEnabled -Name $destRole.displayName -ID $destRole.id
                }

                # Role Permissions
                foreach ($permission in $sourceRole.rolePermissions.allowedResourceActions) {
                    if ($permission -in $destRole.rolePermissions.allowedResourceActions) { continue }

                    $changes += New-Change -Action AddRight -Value $permission -Name $destRole.displayName -ID $destRole.id
                }
                foreach ($permission in $destRole.rolePermissions.allowedResourceActions) {
                    if ($permission -in $sourceRole.rolePermissions.allowedResourceActions) { continue }

                    $changes += New-Change -Action RemoveRight -Value $permission -Name $destRole.displayName -ID $destRole.id
                }

                if (-not $changes) { continue }
                New-TestResult -Category Role -Action Update -Identity $destRole.displayName -SourceObject $sourceRole -DestinationObject $destRole -Change $changes
            }
        }
        #endregion Utility Functions
    }
    process {
        $rolesSource = Get-ErmRole -Tenant Source | Where-Object isBuiltIn -EQ $false
        $rolesDestination = Get-ErmRole -Tenant Destination | Where-Object isBuiltIn -EQ $false

        Test-RoleDeletion -Source $rolesSource -Destination $rolesDestination
        Test-RoleCreation -Source $rolesSource -Destination $rolesDestination
        Test-RoleUpdate -Source $rolesSource -Destination $rolesDestination
    }
}

function Get-ErmRoleMember {
    <#
    .SYNOPSIS
        Lists all members of a role.
     
    .DESCRIPTION
        Lists all members of a role.
        Will include direct assignments, assignment requests and eligibility requests.
 
        Requires an established connection to either the source or destination tenant.
        Use "Connect-ErmService" to establish such a connection.
     
    .PARAMETER Tenant
        The tenant to search.
     
    .PARAMETER Id
        The ID of the role for which to find members.
        Can be either an actual ID or the name of the role.
     
    .EXAMPLE
        PS C:\> Get-ErmRoleMember
 
        Get a list of all role memberships across all roles.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateSet('Source', 'Destination')]
        [string]
        $Tenant,

        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [PsfValidatePattern('^(([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-([0-9a-fA-F]){12})$', ErrorMessage = 'Not a valid Guid / ID')]
        [string[]]
        $Id
    )
    begin {
        $service = "Graph-EntraRoleMigrator-$Tenant"
        Assert-ErmConnection -Service $Tenant -Cmdlet $PSCmdlet

        $roleCache = @{ }
        # Permanent Assignments should be deduplicated from direct role memberships
        $assignCache = @{ }

        $endpointPaths = @(
            'roleManagement/directory/roleEligibilityScheduleRequests'
            'roleManagement/directory/roleAssignmentScheduleRequests'
            'roleManagement/directory/roleAssignments'
        )
    }
    process {
        foreach ($endpointPath in $endpointPaths) {
            if (-not $Id) {
                $query = @{ '$expand' = 'principal' }
                if ($endpointPath -eq 'roleManagement/directory/roleEligibilityScheduleRequests') {
                    # $query['$filter'] = "action eq 'adminAssign'"
                    $query['$filter'] = "status eq 'Provisioned' or Status eq 'Revoked'"
                }
                if ($endpointPath -eq 'roleManagement/directory/roleAssignmentScheduleRequests') {
                    # $query['$filter'] = "action eq 'adminAssign'"
                    $query['$filter'] = "status eq 'Provisioned' or Status eq 'Revoked'"
                }
                try {
                    Invoke-EntraRequest -Service $service -Path $endpointPath -ErrorAction Stop -Query $query |
                        ConvertTo-RoleMembership -Roles $roleCache -Tenant $Tenant -Path $endpointPath -Assigned $assignCache
                }
                catch { $PSCmdlet.WriteError($_) }
                continue
            }
    
            foreach ($roleID in $Id) {
                $query = @{
                    '$expand' = 'principal'
                    '$filter' = "roleDefinitionId eq '$roleID'"
                }
                if ($endpointPath -eq 'roleManagement/directory/roleEligibilityScheduleRequests') {
                    # $query['$filter'] = "roleDefinitionId eq '$roleID' and action eq 'adminAssign'"
                    $query['$filter'] = "roleDefinitionId eq '$roleID' and status eq 'Provisioned'"
                }
                if ($endpointPath -eq 'roleManagement/directory/roleAssignmentScheduleRequests') {
                    # $query['$filter'] = "roleDefinitionId eq '$roleID' and action eq 'adminAssign'"
                    $query['$filter'] = "roleDefinitionId eq '$roleID' and status eq 'Provisioned'"
                }
                try {
                    Invoke-EntraRequest -Service $service -Path $endpointPath -ErrorAction Stop -Query $query |
                        ConvertTo-RoleMembership -Roles $roleCache -Tenant $Tenant -Path $endpointPath -Assigned $assignCache
                }
                catch { $PSCmdlet.WriteError($_) }
            }
        }
    }
}

function Invoke-ErmRoleMember {
    <#
    .SYNOPSIS
        Synchronizes the destination tenant with the source tenant's role memberships.
     
    .DESCRIPTION
        Synchronizes the destination tenant with the source tenant's role memberships.
 
        DANGER!!!
        This command, if used inproperly, can cause significant harm to your ability to manage the destination tenant.
 
        If not other specified, it will compare source and destination tenant and correct anything in the destination tenant,
        that does not match the role assignment configuration of the source tenant.
        It will however not create any identies in the destiantion tenant - a user that only exists in the source will not have
        their role memberships applied in the destination until it is created there as well.
 
        You can preview all changes by calling "Test-ErmRoleMember"
        You can provide the result objects from "Test-ErmRoleMember" as input to this command to pick what changes to apply.
        You can use "Register-ErmIdentityMapping" to define, how principals are matched from the source tenant to the destination tenant.
 
        This command requires an established connection to both the source and destination tenants.
        Use "Connect-ErmService" to establish connections to either tenant.
     
    .PARAMETER TestResult
        The test results to execute.
        Only provide the result objects from "Test-ErmRoleMember" as input.
        If not specified, a full test will be executed and applied.
     
    .PARAMETER EnableException
        This parameters disables user-friendly warnings and enables the throwing of exceptions.
        This is less user friendly, but allows catching exceptions in calling scripts.
     
    .PARAMETER WhatIf
        If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run.
     
    .PARAMETER Confirm
        If this switch is enabled, you will be prompted for confirmation before executing any operations that change state.
 
    .EXAMPLE
        PS C:\> Invoke-ErmRoleMember
 
        Synchronizes the destination tenant with the source tenant's role memberships.
        Might be dangerous without first verifying the pending changes through Test-ErmRoleMember.
 
    .EXAMPLE
        PS C:\> $test | Invoke-ErmRoleMember
 
        Applies the changes in $test.
        This could be filtered results from Test-ErmRoleMember.
 
    .EXAMPLE
        PS C:\> Test-ErmRoleMember | Where-Object { $_.Source.Principal -eq 'FredAdm' .or $_.Destination.Principal -eq 'FredAdm' } | Invoke-ErmRoleMember
 
        Applies all pending changes regarding the user "FredAdm"
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $TestResult,

        [switch]
        $EnableException
    )

    begin {
        $serviceDestination = "Graph-EntraRoleMigrator-Destination"
        Assert-ErmConnection -Service Source -Cmdlet $PSCmdlet
        Assert-ErmConnection -Service Destination -Cmdlet $PSCmdlet

        function New-Schedule {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [OutputType([hashtable])]
            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                $Start,

                [Parameter(Mandatory = $true)]
                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                $End,

                [Parameter(Mandatory = $true)]
                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                $Duration
            )

            $schedule = @{
                startDateTime = $Start
                recurrence = $null
                expiration = @{
                    type = 'noExpiration'
                    endDateTime = $null
                    duration = $null
                }
            }
            if ($End) {
                $schedule.expiration.endDateTime = $End
                $schedule.expiration.type = 'afterDateTime'
            }
            if ($Duration) {
                $schedule.expiration.duration = $Duration
                $schedule.expiration.type = 'afterDuration'
            }

            $schedule
        }
    }
    process {
        $testObjects = $TestResult
        if (-not $testObjects) {
            $testObjects = Test-ErmRoleMember
        }

        foreach ($testItem in $testObjects) {
            #region Validation
            if ($testItem.PSObject.TypeNames -notcontains 'EntraRoleMigrator.TestResult') {
                Write-PSFMessage -Level Warning -Message 'Not a EntraRoleMigrator test result! {0}. Use Test-ErmRole to generate valid test results.' -StringValues $testItem -Target $testItem
                Write-Error "Not a EntraRoleMigrator test result! $testItem"
                continue
            }
            if ($testItem.Category -ne 'Membership') {
                Write-PSFMessage -Level Warning -Message 'Not a Role test result! Use Test-ErmRole to generate valid test results. Input: {0} -> {1}: {2}' -StringValues $testItem.Category, $testItem.Action, $testItem.Identity -Target $testItem
                Write-Error "Not a Role test result: $($testItem.Category) -> $($testItem.Action): $($testItem.Identity)"
                continue
            }
            #endregion Validation

            switch ($testItem.Action) {
                #region Add
                'Add' {
                    switch ($testItem.Source.AssignmentType) {
                        'Permanent' {
                            Invoke-PSFProtectedCommand -Action "Permanently assigning $($testItem.Source.TargetName) to role $($testItem.Source.RoleName)" -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path roleManagement/directory/roleAssignments -Body @{
                                    directoryScopeId = $testItem.Source.DirectoryScopeId
                                    principalId      = $testItem.Source.TargetID
                                    roleDefinitionId = $testItem.Source.TargetRoleID
                                } -ErrorAction Stop
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                        'Eligible' {
                            Invoke-PSFProtectedCommand -Action "Assigning eligibility to $($testItem.Source.TargetName) to role $($testItem.Source.RoleName)" -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path roleManagement/directory/roleEligibilityScheduleRequests -Body @{
                                    action           = 'AdminAssign'
                                    appScopeId       = $testItem.Source.AppScopeId
                                    directoryScopeId = $testItem.Source.DirectoryScopeId
                                    principalId      = $testItem.Source.TargetID
                                    roleDefinitionId = $testItem.Source.TargetRoleID
                                    justification    = 'Automated Role Membership Migration from tenant {0}' -f (Get-EntraToken -Service Graph-EntraRoleMigrator-Source).TenantId
                                    scheduleInfo     = New-Schedule -Start $testItem.Source.ScheduleStart -End $testItem.Source.ScheduleEnd -Duration $testItem.Source.ScheduleDuration
                                }
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                        default {
                            $action = "Assigning $($testItem.Source.TargetName) to role $($testItem.Source.RoleName)"
                            if ($testItem.Source.ScheduleEnd) { $action = "Assigning $($testItem.Source.TargetName) to role $($testItem.Source.RoleName) until $($testItem.Source.ScheduleEnd)" }
                            if ($testItem.Source.ScheduleDuration) { $action = "Assigning $($testItem.Source.TargetName) to role $($testItem.Source.RoleName) for $($testItem.Source.ScheduleDuration)" }

                            Invoke-PSFProtectedCommand -Action $action -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path roleManagement/directory/roleAssignmentScheduleRequests -Body @{
                                    action           = 'AdminAssign'
                                    appScopeId       = $testItem.Source.AppScopeId
                                    directoryScopeId = $testItem.Source.DirectoryScopeId
                                    principalId      = $testItem.Source.TargetID
                                    roleDefinitionId = $testItem.Source.TargetRoleID
                                    justification    = 'Automated Role Membership Migration from tenant {0}' -f (Get-EntraToken -Service Graph-EntraRoleMigrator-Source).TenantId
                                    scheduleInfo     = New-Schedule -Start $testItem.Source.ScheduleStart -End $testItem.Source.ScheduleEnd -Duration $testItem.Source.ScheduleDuration
                                }
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                    }
                }
                #endregion Add

                #region Remove
                'Remove' {
                    switch ($testItem.Destination.AssignmentType) {
                        'Permanent' {
                            Invoke-PSFProtectedCommand -Action "Permanently removing the assignment of $($testItem.Destination.Principal) to role $($testItem.Destination.RoleName)" -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method DELETE -Path "roleManagement/directory/roleAssignments/$($testItem.Destination.AssgignmentID)" -ErrorAction Stop
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                        'Eligible' {
                            Invoke-PSFProtectedCommand -Action "Unassigning eligibility of $($testItem.Destination.Principal) to role $($testItem.Destination.RoleName)" -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path roleManagement/directory/roleEligibilityScheduleRequests -Body @{
                                    action           = 'adminRemove'
                                    appScopeId       = $testItem.Destination.AppScopeId
                                    directoryScopeId = $testItem.Destination.DirectoryScopeId
                                    principalId      = $testItem.Destination.PrincipalID
                                    roleDefinitionId = $testItem.Destination.RoleID
                                    justification    = 'Automated Role Membership Migration from tenant {0}' -f (Get-EntraToken -Service Graph-EntraRoleMigrator-Source).TenantId
                                }
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                        default {
                            $action = "Unassigning $($testItem.Destination.Principal) from role $($testItem.Destination.RoleName)"
                            if ($testItem.Destination.ScheduleEnd -or $testItem.Destination.ScheduleDuration) { $action = "Removing temporary assignment of $($testItem.Destination.Principal) from role $($testItem.Destination.RoleName)" }

                            Invoke-PSFProtectedCommand -Action $action -Target $testItem -ScriptBlock {
                                $null = Invoke-EntraRequest -Service $serviceDestination -Method POST -Path roleManagement/directory/roleAssignmentScheduleRequests -Body @{
                                    action           = 'adminRemove'
                                    appScopeId       = $testItem.Destination.AppScopeId
                                    directoryScopeId = $testItem.Destination.DirectoryScopeId
                                    principalId      = $testItem.Destination.PrincipalID
                                    roleDefinitionId = $testItem.Destination.RoleID
                                    justification    = 'Automated Role Membership Migration from tenant {0}' -f (Get-EntraToken -Service Graph-EntraRoleMigrator-Source).TenantId
                                }
                            } -EnableException $EnableException -PSCmdlet $PSCmdlet -Continue
                        }
                    }
                }
                #endregion Remove

                #region Update
                'Update' {
                    throw "Not Implemented Yet!"
                }
                #endregion Update

                #region Ignore
                'Ignore' {
                    Write-PSFMessage -Level Verbose -Message 'Skipping Ignored Test Result: {0} ({1})' -StringValues $testItem.Identity, $testItem.ChangeDisplay -Target $testItem
                }
                #endregion Ignore
            }
        }
    }
}

function Test-ErmRoleMember {
    <#
    .SYNOPSIS
        Tests, what configuration discrepancies exist between source and destination tenant.
     
    .DESCRIPTION
        Tests, what configuration discrepancies exist between source and destination tenant.
        Requires a connection to both tenants.
        Use "Connect-ErmService" to establish such connections.
     
    .PARAMETER IncludeIgnored
        Whether ignored results should be included in the output.
        Discrepancies might be ignored if the principal cannot be found in the destination tenant.
     
    .EXAMPLE
        PS C:\> Test-ErmRoleMember
 
        Tests, what configuration discrepancies exist between source and destination tenant.
    #>

    [CmdletBinding()]
    param (
        [ValidateSet('All', 'Multiple', 'Unresolved', 'Unsupported', 'NoRole')]
        [string[]]
        $IncludeIgnored
    )
    begin {
        Assert-ErmConnection -Service Source -Cmdlet $PSCmdlet
        Assert-ErmConnection -Service Destination -Cmdlet $PSCmdlet

        #region Utility Functions
        function Get-IntendedAssignment {
            [CmdletBinding()]
            param (
                $Source,

                $Destination,

                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                [string[]]
                $IncludeIgnored
            )

            foreach ($sourceAssignment in $Source) {
                $identity = '{0}-->{1}' -f $sourceAssignment.RoleName, $sourceAssignment.Principal
                # Handle the cases we can't perform due to missing principal in destination or other related issues
                if ($sourceAssignment.TargetResult -ne 'Single') {
                    if ('All' -in $IncludeIgnored -or $sourceAssignment.TargetType -in $IncludeIgnored) {
                        New-TestResult -Category Membership -Action Ignore -SourceObject $sourceAssignment -Identity $identity -Change @(
                            New-Change -Action Ignore -Property Issue -Value $sourceAssignment.TargetResult -Name $sourceAssignment.RoleName -ID $sourceAssignment.PrincipalID
                        )
                    }
                    continue
                }
                if (-not $sourceAssignment.TargetRoleID) {
                    if ('All' -in $IncludeIgnored -or 'NoRole' -in $IncludeIgnored) {
                        New-TestResult -Category Membership -Action Ignore -SourceObject $sourceAssignment -Identity $identity -Change @(
                            New-Change -Action Ignore -Property Issue -Value 'NoRole' -Name $sourceAssignment.RoleName -ID $sourceAssignment.PrincipalID
                        )
                    }
                    continue
                }

                # Exclude matching assignments
                $matchingDest = $Destination | Where-Object {
                    $_.RoleName -eq $sourceAssignment.RoleName -and
                    $_.PrincipalType -eq $sourceAssignment.PrincipalType -and
                    $_.AssignmentType -eq $sourceAssignment.AssignmentType -and
                    $_.PrincipalID -eq $sourceAssignment.TargetID
                }
                if ($matchingDest) { continue }

                # Report missing assignments
                New-TestResult -Category Membership -Action Add -Identity $identity -SourceObject $sourceAssignment
            }
        }

        function Get-UndesiredAssignment {
            [CmdletBinding()]
            param (
                $Source,

                $Destination
            )

            foreach ($destinationAssignment in $Destination) {
                $identity = '{0}-->{1}' -f $destinationAssignment.RoleName, $destinationAssignment.Principal

                $matchingSource = $Source | Where-Object {
                    $_.TargetResult -eq 'Single' -and
                    $_.PrincipalType -eq $destinationAssignment.PrincipalType -and
                    $_.RoleName -eq $destinationAssignment.RoleName -and
                    $_.AssignmentType -eq $destinationAssignment.AssignmentType -and
                    $_.TargetID -eq $destinationAssignment.PrincipalID
                }
                if ($matchingSource) { continue }

                New-TestResult -Category Membership -Action Remove -Identity $identity -DestinationObject $destinationAssignment
            }
        }

        function Get-UpdatedAssignment {
            [CmdletBinding()]
            param (
                $Source,

                $Destination
            )

            foreach ($sourceAssignment in $Source) {
                $matchingDest = $Destination | Where-Object {
                    $_.RoleName -eq $sourceAssignment.RoleName -and
                    $_.PrincipalType -eq $sourceAssignment.PrincipalType -and
                    $_.AssignmentType -eq $sourceAssignment.AssignmentType -and
                    $_.PrincipalID -eq $sourceAssignment.TargetID
                }
                if (-not $matchingDest) { continue }

                $changes = @()

                # Note: This may need further identity/resource resolution to properly match
                if ($sourceAssignment.DirectoryScopeId -ne $matchingDest.DirectoryScopeId) {
                    $changes += New-Change -Action Update -Property DirectoryScopeId -Value $sourceAssignment.DirectoryScopeId -Name $matchingDest.RoleName -ID $matchingDest.RoleID
                }
                
                #region Eligibility Settings
                $propertyNames = 'ScheduleStart', 'ScheduleEnd', 'EligibleMemberType', 'AppScopeId'
                foreach ($propertyName in $propertyNames) {
                    if ($sourceAssignment.$propertyName -eq $matchingDest.$propertyName) { continue }
                    if ($propertyName -eq 'ScheduleStart') {
                        # Assignments that are applicable right away will be created with the current time in the destination tenant
                        # Thus, it will IGNORE Start timestamps that are in the past and assignments will not match timestamps in that scenario
                        if (
                            $sourceAssignment.ScheduleStart -lt (Get-Date).ToUniversalTime() -and
                            $matchingDest.ScheduleStart -lt (Get-Date).ToUniversalTime()
                        ) { continue }
                    }
                    $changes += New-Change -Action Update -Property $propertyName -Value $sourceAssignment.$propertyName -Name $matchingDest.RoleName -ID $matchingDest.RoleID
                }
                #endregion Eligibility Settings


                if (-not $changes) { continue }

                $identity = '{0}-->{1}' -f $sourceAssignment.RoleName, $sourceAssignment.Principal
                New-TestResult -Category Membership -Action Update -Identity $identity -SourceObject $sourceAssignment -DestinationObject $matchingDest -Change $changes
            }
        }
        #endregion Utility Functions
    }
    process {
        # Get Assignments
        $sourceAssignments = Get-ErmRoleMember -Tenant Source
        $destinationAssignments = Get-ErmRoleMember -Tenant Destination

        # Map Source Identities to Destination Identities
        $extendedSourceAssignments = $sourceAssignments | Resolve-DestinationIdentity
        
        # Compare Assignments
        Get-IntendedAssignment -Source $extendedSourceAssignments -Destination $destinationAssignments -IncludeIgnored $IncludeIgnored
        Get-UndesiredAssignment -Source $extendedSourceAssignments -Destination $destinationAssignments
        Get-UpdatedAssignment -Source $extendedSourceAssignments -Destination $destinationAssignments
    }
}

$source = @'
namespace EntraRoleMigrator
{
    public enum PrincipalType
    {
        user,
        servicePrincipal
    }
}
'@

if (-not ([System.Management.Automation.PSTypeName]'EntraRoleMigrator.PrincipalType').Type) {
    Add-Type -TypeDefinition $source -ErrorAction Stop
}

# The Different ways Test Results will show their "Changes" column
$script:_ResultDisplayStyles = New-PSFHashtable -DefaultValue {
    if (-not $this.Changes) { return $this.Destination }
    $this.Changes -join ', '
}

# Contains the logic to match identities from source to desintation tenant.
# Used when resolving intended Role Memberships
$script:_IdentityMapping = @{ }

$graphCfg = @{
    Name          = 'Graph-EntraRoleMigrator-Source'
    ServiceUrl    = 'https://graph.microsoft.com/v1.0'
    Resource      = 'https://graph.microsoft.com'
    DefaultScopes = @()
    HelpUrl       = 'https://developer.microsoft.com/en-us/graph/quick-start'
    Header        = @{ 'content-type' = 'application/json' }
}
Register-EntraService @graphCfg

$graphCfg = @{
    Name          = 'Graph-EntraRoleMigrator-Destination'
    ServiceUrl    = 'https://graph.microsoft.com/v1.0'
    Resource      = 'https://graph.microsoft.com'
    DefaultScopes = @()
    HelpUrl       = 'https://developer.microsoft.com/en-us/graph/quick-start'
    Header        = @{ 'content-type' = 'application/json' }
}
Register-EntraService @graphCfg

# Role
$script:_ResultDisplayStyles['Role-Delete'] = {
    'Delete: {0}' -f $this.Change.Name
}
$script:_ResultDisplayStyles['Role-Create'] = {
    'Create: {0}' -f $this.Change.Name
}
$script:_ResultDisplayStyles['Role-Update'] = {
    $__items = foreach ($change in $this.Change) {
        switch ($change.Action) {
            'Update' { '{0}: {1}' -f $change.Property, $change.Value }
            'AddRight' { '+{0}' -f $change.Value }
            'RemoveRight' { '-{0}' -f $change.Value }
        }
    }
    $__items -join "`n"
}
$script:_ResultDisplayStyles['Membership-Ignore'] = {
    'Ignore: {0}' -f $this.Change.Value
}
$script:_ResultDisplayStyles['Membership-Update'] = {
    $__items = foreach ($change in $this.Change) {
        switch ($change.Action) {
            'Update' { '{0}: {1}' -f $change.Property, $change.Value }
        }
    }
    $__items -join ', '
}
$script:_ResultDisplayStyles['Membership-Remove'] = { }
$script:_ResultDisplayStyles['Membership-Add'] = { }

Register-PSFTeppScriptblock -Name 'EntraRoleMigrator.IdentityMapping.Type' -ScriptBlock {
    (Get-ErmIdentityMapping).Type | Sort-Object -Unique
}

Register-PSFTeppScriptblock -Name 'EntraRoleMigrator.IdentityMapping.Name' -ScriptBlock {
    $type = '*'
    if ($fakeBoundParameters.Type) {$type = $fakeBoundParameters.Type }
    (Get-ErmIdentityMapping -Type $type).Name | Sort-Object -Unique
}

$param = @{
    Type = 'user'
    Name = 'default'
    Priority = 100
    SourceProperty = 'userPrincipalName'
    DestinationProperty = 'userPrincipalName'
    Conversion = { $_ }
}
Register-ErmIdentityMapping @param

$param = @{
    Type = 'servicePrincipal'
    Name = 'default'
    Priority = 100
    SourceProperty = 'displayName'
    DestinationProperty = 'displayName'
    Conversion = { $_ }
}
Register-ErmIdentityMapping @param

$param = @{
    Type = 'servicePrincipal'
    Name = 'byID'
    Priority = 99
    SourceProperty = 'id'
    DestinationProperty = 'id'
    Conversion = {
        if (-not $global:spnIDMap) { return }
        $global:spnIDMap[$_]
    }
}
Register-ErmIdentityMapping @param