Share via


Migrating TFS to a new data center

Applies to: TFS 2013 | TFS 2015 | TFS 2017

This article outlines an approach for performing TFS migrations to a new data center (in a different domain) where there is limited or no connectivity between the current and target environments. In this context, migrating the TFS deployment includes restoring backups of the TFS (config & collections), SharePoint (content), and SSRS (reports) databases in the new domain environment. If you are faced with this scenario, read on. Even if this does not match your scenario, some parts of the information covered here may still be useful when dealing with large numbers of changes to existing TFS, SharePoint, and SSRS user identities and permissions.

Background

There are two documented types of moves you can perform on Team Foundation Server: hardware and environment. In a hardware move, you are moving (or cloning) an existing TFS implementation to a new set of servers in the same domain. In an environment move, you are changing the domain of the TFS deployment. Documentation for both types of moves is available here:

Both of these articles have the following important note:

"In some situations you might want to change the domain of a TFS deployment as well as its hardware. Changing the domain is an environment-based move, and you should never combine the two move types. First complete the hardware move, and then change the environment."

But what if your migration is to a new data center or servers located in a different domain, with restricted or no connectivity to the original domain? This scenario can occur when changing data centers, for example during mergers of separate organizations or transfer of responsibility for services. With limited or no connectivity between environments, it is not possible to join the existing TFS deployment to the new domain. In this case, the hardware move is the closer match for what we need to achieve.

Following the instructions in the hardware move documentation gets us though most of the migration process. After the hardware move has been completed we will still need to remap user identities/permissions for TFS, SharePoint, and SSRS. A common element to each of these three areas is the mapping of your user identities from the old domain into the new domain. We'll look at this requirement in the User Mapping File section below.

One thing you should pay special attention to during the process is ensuring that any TFS user identities in the new domain that you will be mapping TFS users from the old domain into are not part of the Local Administrators group. This is important because the TFS identity synchronization service will automatically create TFS user identities for members of this group. Once a TFS user identity has been created in the new domain, it is not possible to map a TFS user identity from the old domain into it.

User Mapping File

To support the user identity and permission changes needed in the three identified areas, we will create a common user mapping text file. In this schema, OldDomain represents the name of the original domain, OldAccount represents the user account names in the original domain, NewDomain represents the name of the target domain, and NewAccount represents the user account names in the target domain.

The following illustrates a user mapping file for a sample scenario where TFSDEV is the original domain and TFSPRD is the target domain:

 OldDomain,OldAccount,NewDomain,NewAccount
TFSDEV,Developer1,TFSPRD,Developer1
TFSDEV,Developer3,TFSPRD,Developer2

The first line of this file contains the attribute names, which must match exactly for the PowerShell scripts provided later to operate correctly. In this sample file, we are mapping the Developer1 account in the TFSDEV domain to the same account name in the TFSPRD domain. The Developer3 user account in the TFSDEV domain is mapped to Developer2 in the TFSPRD domain.

We've chosen the CSV format for the user mapping file in this example as it is relatively easy to manage with a wide range of application tools and utilities. However, you could also use other formats, with the appropriate changes to the sample scripts. For example, JSON and XML are two possible alternatives.

Once the user mapping file has been created, we can proceed with the changes detailed in the following three sections:

  1. Remap TFS user identities
  2. Remap SharePoint user identities
  3. Update SSRS user permissions

TFS Identities

Remapping TFS identities is accomplished using the TfsConfig Identities /change command. The TfsConfig.exe utility is installed with Team Foundation Server, under the \Tools folder of the installation path. If all (or most) of your account names match between the two domain environments, then you can run the command directly, omitting the /account and /toaccount parameters. Often times however, you will need to map many user accounts that have different names in each domain.

tfs-change-identities.ps1

The following PowerShell script illustrates how you can handle the more complex account mapping scenario, using the user mapping file created in the previous section. The $TfsConfig parameter in this sample script points to the location of the TfsConfig.exe application in a default installation of TFS 2015. If its location is different in your environment, you will need to either change the default value in the script, or provide the location as a parameter value when invoking the script.

 [CmdletBinding()]
param (
    [Parameter(Mandatory=$true, Position=0)]
    [string] $UserMapFile,
    [Parameter(Position=1)]
    [string] $TfsConfig = "C:\Program Files\Microsoft Team Foundation Server 14.0\Tools\TfsConfig.exe"
)

if (-Not (Test-Path $TfsConfig))
{
    Write-Error "TfsConfig.exe file does not exist in the specified location"
    return
}

if (-Not (Test-Path $UserMapFile))
{
    Write-Error "User map file does not exist in the specified location"
    return
}

$usermap = Import-Csv $UserMapFile

foreach ($user in $usermap)
{
    Write-Host "Changing $($user.OldDomain)\$($user.OldAccount)" -NoNewline
    Write-Host " to $($user.NewDomain)\$($user.NewAccount)"

    & $TfsConfig Identities /change `
        /fromdomain:$($user.OldDomain) `
        /todomain:$($user.NewDomain) `
        /account:$($user.OldAccount) `
        /toaccount:$($user.NewAccount)
}

Running this script with the user mapping file created in the previous section displays the following output:

 
PS C:\> .\tfs-change-identities.ps1 c:\docs\usermap.csv
Changing TFSDEV\Developer1 to TFSPRD\Developer1
Account Name           Exists (see note 1)  Matches (see note 2)
TFSPRD\Developer1      True                 False                  Changed
1 security identifier(s) (SIDs) were changed in Team Foundation Server.
== NOTES ==
(1) The Exists column indicates whether the listed account exists in Windows. For the List mode of the command, this is the account stored in Team Foundation Server. For the Change mode, it is the target of the change.
(2) The Matches column indicates whether the SID stored in Team Foundation Server matches with Windows.

The script executes the TfsConfig Identities /change command for each entry in the user mapping file, and will display the Changed value on success. Changed TFS identities will not match the Windows user identities until the next identity synchronization has occurred - by default, this happens at the top of every hour. It is possible to force an identity synchronization to happen immediately by invoking a web service call. Stay tuned for a future post describing that process.

SharePoint Identities

After moving your SharePoint content database for TFS to a new domain, the SharePoint user permissions still reference user identities in the original domain environment.

sp-change-identities.ps1

It is relatively easy to remap the SharePoint user identities by using the Move-SPUser PowerShell cmdlet. The following PowerShell script illustrates how you can use the previously created User Mapping File to remap the SharePoint user identities into the new domain environment.

 [CmdletBinding()]
param (
    [Parameter(Mandatory=$true, Position=0)]
    [string] $WebAppUrl,
    [Parameter(Mandatory=$true, Position=1)]
    [string] $UserMapFile
)
$ErrorActionPreference = "Stop"
Add-PSSnapIn "Microsoft.SharePoint.Powershell"

$moveErrors = $null
$usermap = Import-Csv $UserMapFile

$users = Get-SPWebApplication $WebAppUrl | Get-SPSite | Get-SPWeb | Get-SPUser

foreach ($user in $users)
{
    $parts = $user.UserLogin.Split('\')
    if ($parts.Count -ne 2) { continue }
    $domain = $parts[0]
    $account = $parts[1]

    foreach ($entry in $usermap)
    {
        if ($domain -eq $entry.OldDomain -and $account -eq $entry.OldAccount)
        {
            $oldAlias = $domain + "\" + $account
            $newAlias = $entry.NewDomain + "\" + $entry.NewAccount
            Write-Host "Changing SharePoint user from [" -ForegroundColor Yellow -NoNewline
            Write-Host $oldAlias "] to [" $newAlias "]" -ForegroundColor Yellow
            Move-SPUser -Identity $user -NewAlias $newAlias -IgnoreSID -ErrorAction SilentlyContinue
        }
    }
}

Run the above script, supplying the URL of the SharePoint web application associated with the TFS SharePoint site collections, and location of the user mapping file. Typically you will run this on the SharePoint server in the new environment, which should already have the Microsoft.SharePoint.Powershell module present.

Running this script produces the following sample output:

 
PS C:\> .\sp-change-identities.ps1 https://tfs-prd-at/ .\usermap.csv

Changing SharePoint user from [TFSDEV\Developer1] to [TFSPRD\Developer1]
Changing SharePoint user from [TFSDEV\Developer3] to [TFSPRD\Developer2]

After remapping your SharePoint user identities, login with a user in the new domain and navigate to a SharePoint site associated with one or more of your TFS team projects to validate that the SharePoint user was remapped successfully.

SSRS Permissions

The last area we'll address is SQL Server Reporting Services (SSRS) permissions for your TFS team project reports. Managing SSRS user permissions (roles) can be done through the Report Manager web interface as documented in Grant user access to a report server, or via the command-line with the RS.exe utility.

This section describes a PowerShell-based solution to simplify the replication of hundreds (or thousands) of distinct SSRS user permissions between different domain environments. We will once again leverage the User Mapping File already created, combined with two PowerShell scripts to: 1) retrieve existing user permissions in the original domain environment, and 2) reapply those permissions in the new domain environment.

ssrs-list-permissions.ps1

The first step is to generate a textual representation of user permissions in the original domain environment. It takes two parameters: the SSRS report server starting path, and an SSRS instance name if this is not a default SSRS instance. The script should be run on the SSRS server in the original domain environment. It can be run from elsewhere by changing the $reportServerUri variable to point to the SSRS Web Service URL (available in Reporting Services Configuration Manager).

 <#
    PowerShell script to iterate over non-inherited SSRS permissions.
    1. Change the $sourceFolderPath value if you want to start at a lower
       level than root of SSRS.
    2. Change the $InstanceName if this is not the SSRS default instance
#>
[CmdletBinding()]
param (
    [Parameter(Position=0)]
    [string] $sourceFolderPath = "/",
    [Parameter(Position=1)]
    [string] $InstanceName = ""
)

if ($InstanceName -eq "")
{
    $reportServerUri = "https://localhost/ReportServer/ReportService2010.asmx"
}
else
{
    $reportServerUri = "https://localhost/ReportServer_" + $InstanceName + "/ReportService2010.asmx"
}

$scriptDirectory = Split-Path $MyInvocation.MyCommand.Path
$outfile = $scriptDirectory + "\ssrs-list-permissions.csv"

Write-Host "SSRS web service:  " $reportServerUri -ForegroundColor Yellow
Write-Host "Working directory: " $scriptDirectory -ForegroundColor Yellow
Write-Host "Computer name:     " $env:COMPUTERNAME -ForegroundColor Yellow
Write-Host "Logged in as:      " $env:USERNAME -ForegroundColor Yellow

$inheritParent = $true
$output = @()

$proxy = New-WebServiceProxy -UseDefaultCredential -Uri $reportServerUri

$items = $proxy.ListChildren($sourceFolderPath, $true) `
    | Select-Object TypeName, Path, Name

foreach ($item in $items)
{
    Add-Member -InputObject $item -MemberType NoteProperty -Name RoleName -Value ''
    Add-Member -InputObject $item -MemberType NoteProperty -Name FullyQualifiedUserName -Value ''
    Add-Member -InputObject $item -MemberType NoteProperty -Name Domain -Value ''
    Add-Member -InputObject $item -MemberType NoteProperty -Name UserName -Value ''

    $needHeader = $true
    foreach ($policy in $proxy.GetPolicies($item.path, [ref]$inheritParent))
    {
        if ($inheritParent) { continue }

        if ($needHeader)
        {
            Write-Host
            Write-Host "Type: " $item.TypeName -ForegroundColor Yellow
            Write-Host "Path: " $item.Path -ForegroundColor Yellow
            Write-Host "Name: " $item.Name -ForegroundColor Yellow
            Write-Host "--------------------------------------------------"
            $needHeader = $false
        }

        Write-Host $policy.GroupUserName -NoNewline
        Write-Host " (" -NoNewline
        $needComma = $false
        foreach ($role in $policy.Roles)
        {
            if ($needComma) { Write-Host ", " -NoNewline } else { $needComma = $true }
            Write-Host $role.Name -ForegroundColor Green -NoNewline
            $temp = $item.PsObject.Copy();
            $temp.RoleName = $role.Name
            $temp.FullyQualifiedUserName = $policy.GroupUserName;
            $temp.Domain = $policy.GroupUserName.Split('\')[0]
            $temp.UserName = $policy.GroupUserName.Split('\')[1]
            $output += $temp;
            $temp.reset;
        }
        Write-Host ")"
    }
}

$output | Export-csv $outfile -NoTypeInformation;

Write-Host
Write-Host "Completed with output stored in: " $outfile
Write-Host

This script creates an output file named ssrs-list-permissions.csv in the same directory where the script resides. Remember the location and name of this output file as it will be needed in the next part of the process.

Sample console output from running the ssrs-list-permissions.ps1 script looks like this:

 
PS C:\> .\ssrs-list-permissions.ps1

Type:  Folder
Path:  /TfsReports/DefaultCollection/Agile
Name:  Agile

TFSDEV\Developer1 (Content Manager, Publisher)
TFSDEV\Developer3 (Browser)
Completed with output stored in: ssrs-list-permissions.csv

The permissions output file has the following format:

 TypeName,Path,Name,RoleName,FullyQualifiedUserName,Domain,UserName
Folder,/TfsReports/DefaultCollection/Agile,Agile,Content Manager,TFSDEV\Developer1,TFSDEV,Developer1
Folder,/TfsReports/DefaultCollection/Agile,Agile,Publisher,TFSDEV\Developer1,TFSDEV,Developer1
Folder,/TfsReports/DefaultCollection/Agile,Agile,Browser,TFSDEV\Developer3,TFSDEV,Developer3

If a user is assigned to multiple roles, each role will be specified on a separate line in the permissions output file generated by the ssrs-list-permissions.ps1 script.

ssrs-change-permissions.ps1

The second step is to use the User Mapping File along with the permissions output file generated in the previous step. Together, these two files are used as input to the script below that is run on the SSRS server in the new domain environment.

This script uses the SSRS web service to update permission policies. For each object/role permission identified in the the permissions output file generated in the original domain, it performs a lookup in the user mapping file to determine user identity in the new domain, and grants that identity the object/role permission.

 <#
    The first parameter (ssrsPermissionsFile) is the file path to the output
      from the 'ssrs-list-permissions.ps1' script run in the old domain.
    The second parameter (UserMapFile) is the file path to the user mapping file.
    The third parameter (ssrsInstanceName) is the SSRS instance name. If running
      on the default SSRS instance, press <Enter> if prompted to supply
      an empty string value.
#>
[CmdletBinding()]
param (
    [Parameter(Mandatory=$true, Position=0)]
    [string] $ssrsPermissionsFile,
    [Parameter(Mandatory=$true, Position=1)]
    [string] $UserMapFile,
    [Parameter(Mandatory=$true, Position=2)]
    [AllowEmptyString()]
    [string] $ssrsInstanceName
)

if ($ssrsInstanceName -eq "")
{
    $reportServerUri = "https://localhost/ReportServer/ReportService2010.asmx"
}
else
{
    $reportServerUri = "https://localhost/ReportServer_" + $ssrsInstanceName + "/ReportService2010.asmx"
}

if (-Not (Test-Path $ssrsPermissionsFile))
{
    Write-Error $ssrsPermissionsFile " file does not exist in the specified location"
    return
}

if (-Not (Test-Path $UserMapFile))
{
    Write-Error $UserMapFile " file does not exist in the specified location"
    return
}

Write-Host "Loading SSRS permissions (old domain) from file: " $ssrsPermissionsFile
$permissions = Import-Csv $ssrsPermissionsFile

Write-Host "Loading user mapping file: " $UserMapFile
$usermap = Import-Csv $UserMapFile

$lookup = @{}
$usermap | % { $key = $_.OldDomain + "\" + $_.OldAccount; $lookup[$key] = $_ }

$inheritParent = $true

Write-Host "Connecting to SSRS web service: " $reportServerUri
$proxy = New-WebServiceProxy -UseDefaultCredential -Uri $reportServerUri
$type = $proxy.GetType().Namespace
$policyType = "{0}.Policy" -f $type
$roleType = "{0}.Role" -f $type

foreach ($permission in $permissions)
{
    $key = $permission.FullyQualifiedUserName
    $user = $lookup[$key]

    if ($user -ne $null)
    {
        $identity = $user.NewDomain + "\" + $user.NewAccount

        $message = "Retrieving policies for user '{0}' on '{1}'" -f $identity, $permission.Path
        Write-Host $message -ForegroundColor Yellow

        $policies = $proxy.GetPolicies($permission.Path, [ref]$inheritParent);

        $policy = $policies |
            where { $_.GroupUserName -eq $identity } |
            select -First 1

        if (-not $policy)
        {
            $policy = New-Object ($policyType)
            $policy.GroupUserName = $identity
            $policy.Roles = @()
            $policies += $policy
            $message = "Creating policy for user: '{0}'" -f $identity
            Write-Host $message -ForegroundColor Cyan
        }
 
        $role = $policy.Roles |
            where { $_.Name -eq $permission.RoleName } |
            select -First 1

        if (-not $role)
        {
            $role = New-Object ($roleType)
            $role.Name = $permission.RoleName
            $policy.Roles += $role
            $message = "Adding user '{0}' to role '{1}'" -f $identity, $permission.RoleName
            Write-Host $message -ForegroundColor Cyan
        }

        $message = "Saving policies for user '{0}' on '{1}'" -f $identity, $permission.Path
        Write-Host $message -ForegroundColor Cyan
        $proxy.SetPolicies($permission.Path, $policies);
    }
    else
    {
        $message = "'{0}' not found in user mapping file" -f $key
        Write-Host $message -ForegroundColor Red
    }
}

Sample console output from running the ssrs-change-permissions.ps1 script looks like this:

 
PS C:\> C:\Scripts\ssrs-change-permissions.ps1 .\ssrs-list-permissions.csv .\usermap.csv ""
Loading SSRS permissions (old domain) from file: ssrs-list-permissions.csv
Loading user mapping file: usermap.csv
Connecting to SSRS web service: https://localhost/ReportServer/ReportService2010.asmx
Retrieving policies for user 'TFSPRD\Developer1' on '/TfsReports/DefaultCollection/Agile'
Creating policy for user: 'TFSPRD\Developer1'
Adding user 'TFSPRD\Developer1' to role 'Content Manager'
Saving policies for user 'TFSPRD\Developer1' on '/TfsReports/DefaultCollection/Agile'
Adding user 'TFSPRD\Developer1' to role 'Publisher'
Saving policies for user 'TFSPRD\Developer1' on '/TfsReports/DefaultCollection/Agile'
Retrieving policies for user 'TFSPRD\Developer2' on '/TfsReports/DefaultCollection/Agile'
Creating policy for user: 'TFSPRD\Developer2'
Adding user 'TFSPRD\Developer2' to role 'Browser'
Saving policies for user 'TFSPRD\Developer2' on '/TfsReports/DefaultCollection/Agile'

Conclusion

We've demonstrated how you can leverage PowerShell scripting to simplify the process of managing user identities in a specific migration scenario for an enterprise-scale TFS deployment.