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 |