Active Directory Forest Migration

Edit: Before reading, I want to make it clear that this is a very specific business case scenario in which you are migrating forest’s rather than introducing new 2012R2 Domain Controllers and demoting the 2008R2 DC’s. While this is a rare scenario I wanted to put some information out there encase anyone finds themselves in need of it. Of course before you even think about this sort of project you will want to do all of the associated ground work that comes with a forest migration.

 

So, you’ve been tasked with a very specific job over migrating users from one forest to another as part of a hypothetical business merger or forest rename.

Well… Let’s talk about how were going to do this. Now I’m not saying this is going to be simple or make a lot of sense at first, but I’ll try to get you there in one piece.

Let’s start with the tools we’re going to be using:

  • PowerShell
  • ADMT
  • SQL Server Express (2008)
  • Password Migration Tool

What I want to show more than anything with this post is that (if you didn’t hear it from me enough) PowerShell is awesome for automation. Even with tasks such as an AD Migration you will save countless hours by using PowerShell to automate the migration process.

I’m going to assume for this post that you are going from 2008R2 to 2012R2. With 2016 right around the corner this can be a strange move. However, I understand the need to do this. So let’s get started.

Step 1: Understanding

Whoa there, before we get to crazy automating this process we need to fully understand what the hell we’re doing and why. Things you should understand before even looking into this sort of task are:

  • Why am I doing this?
  • What is the business need?
  • Which services that rely on AD authentication within your environment?
  • How are we going to stage this migration?

If you cannot answer all of these question’s you will want to call a timeout.

You will also want to make sure you’ve sorted out a standard of naming users/groups and any sort of theory type work that is going to take place in the new forest before commencing any of this sort of work to avoid hold up’s.
Like anything you want to do well, you need to spend time investigating the current state of your domain. If you simply just lift and shift your domain over to a new fresh environment would it really be called a success?

You may have hundreds of old groups, disabled users, obsolete GPO’s. It’s for this reason I would suggest using PowerShell to really dig into your environment to pull out any weeds before you move anything over to the new domain.

Let’s take a look at how we can use PowerShell to dig deeper into Users, Computers, Groups and GPO’s. I’ve made a quick function as an example which can be found here

Once you’ve had a look at your environment and done some cleaning up I would suggest creating multiple CSV files (we’ll use these later) with stages of whom will be migrated. Maybe you want to migrate a certain business unit before another, maybe in smaller chucks. This preparation step is super important so don’t sleep on this.

Step 2: Begin

This step assumes you’ve created a 2012R2 Server which is not part of the current 2008R2 domain.

Firstly, our 2012R2 server will need to be a Domain Controller itself. In order to do that we can simply install the AD-Domain-Services feature and create a new forest.

Install-ADDSForest -DomainName NewDomain.corp.com `
-DatabasePath 'C:\NTDS' `
-DomainNetbiosName 'NEWDOMAIN' `
-LogPath 'C:\NTDS' `
-SysvolPath 'C:\Sysvol' `
-Verbose `
-Force

So now we’ve got our fresh new domain up and running. Let’s create a user that we’re going to use through this migration to do all of our migration tasks. We’ll create this user in both the old domain and the new domain.

For the sake of simplicity I am going to call mine ‘MigrationAdmin’.
Once created he will need to be part of the Domain Admin group in each domain respectively.
Transitional Trust
Next, let’s create a transitional trust between our two forests:
trusts
Using the GUI we can create a trust as such
trusts2
NETBIOS name of other domain
ForrestTrustComplete2012
Step through the wizard until you have a two way trust using Forest-wide Authentication on both sides
Conditional Forwarders
Once you’ve got a transitive trust setup between both domains using forest wide authentication we can move onto the next step. Before we install the ADMT Tool and SQL we’ll need to setup a DNS Conditional forwarder so that DNS knows exactly how to handle requests for the other domain.
ConditionalForwarder
Creating a new conditional forward in DNS (this is from newdomain)
ConditionalForwarder1
Complete the conditional forward for both domains (vise versa forwarding)
You should now be able to query AD in the other forest and return objects without any issues. At this stage you will want to go and add the ‘MigrationAdmin’ (for both domains) user to be an built-in Administrator on the opposite domains. We don’t want any weird permission issues popping up when we’re migrating objects to the new domain.
SQL

Let’s now move onto setting up the environment we’re going to need for this all to work. The ADMT tool requires that a SQL server be installed in order for it to store information on the migration process among other things. For the process of this post I’m going to be using SQL Express. In my experience the ADMT tool will freak at you if you try to use any other version of than 2008 SP1 of SQL Express.

So SQL requires .Net 3.5 to be installed so we better do that:

Get-WindowsFeature Net-Framework-Features | Install-WindowsFeature -verbose

Once that’s installed we can install SQL. like I mentioned earlier, I’m installing SQL express 2008 on my 2012R2 (New domain) Domain Controller. There are pro’s and con’s of this however you can simply remove SQL after the migration process.

Let’s fire up our installer:

InstallingSQL
You may see this error occur when running the 2008 SQL Express installer
SQLSERVER
Green!
SQLGREEN
You’ll see this warning if you install SQL Express
Database Engine Service
Database Engine Service
SQLConfig
With Windows authentication mode enabled add both accounts as SQL administrators
The rest of the options in the SQL installation can be left as default, including the service in which SQL will run under. After that’s all finished up we can move onto the next step.
Active Directory Migration Tool (en-US admtsetup32.exe)

Let’s install the ADMT now and come back to using a bit later on. It has a very simple installation wizard.

1
Wizard
2
My local SQL Express instance
3
All done!

 

Password Migration tool (en-US-pwdmig.msi)

This tool allows us to export the password’s from AD user objects.

This is important since we don’t want our users to even notice that they have been migrated. The nature of this tool requires it to be installed on the ‘olddomain‘ domain controller. Before we can jump into installing it on the source domain we need to extract a .PES file from the ‘newdomain‘ that will allow us to unencrypt those passwords as they come in from the migration.

On our target 2012 R2 domain controller let’s fire up a cmd or powershell (Administrator) console and change directories to where you’ve installed the ADMT.exe file. By default this is C:Windows\ADMT.

From here we need to generate that .PES key. so let’s run:

admt key /option:create /sourcedomain:olddomain.corp.com /keyfile:"c:\PES.pes" /keypassword:*

You’ll notice here in the source domain I’ve put in ‘olddomain.corp.com’ . You will want to adjust this to your source domain of course. After you’ve executed this command and put in a password associated with the .PES file you should have a nice export sitting where you specified above. Let’s copy that .PES file over to our source domain controller now so that we can setup the password migration tool.

So now, once you’ve copied that file over to the source DC you would fire up the password migration tool and hit next, next, finish. But hold up. I noticed this weird error when trying to import the .PES file.

4

I’m pretty sure that this error has no idea what it’s talking about. You may of come across this before and banged your head against a way for many hours.

Here is how to get around this silly error.

  • Open up PowerShell (or cmd) as Administrator
  • Navigate to your en-US pwdmig.msi
  • Execute the .msi
5
Once run as Administrator from a shell it works fine!

 

Once you’ve hit Install you will be prompted to choose an account in which the Service will run under. For the sake of this blog post I’m going to go with the default and just say Local System. However when you’re doing this in production you may want to create a new account for running this service.

6
using the Local System account to run the Password Migration Service

After the installation your source DC will need a restart.

Once restarted you should see the Password Export Server Service (PesSvc) in a stopped state.

Let’s start that up. You’ll want to keep an eye on this Service since its start-up type is manual. Remember you will need it running to export any passwords during migration.

Step 3: Migration

Now, if you’ve made it this far I expect you to be feeling pretty confident about this migration process. You’ve done some more reading on the side, You’ve setup this whole environment in a Dev area away from anything even remotely production.

Let’s get started by migrating a test user via the GUI so we know what to expect when automating a process like this in PowerShell.

GUI Migration
7
Fire up ADMT from the target domain
8
We’ll do a user migration in this example
9
To: and From:
10
Choose a single or read from an include file.
11
Make a test user and set them as a target to migrate
12
Migrate the users password
13
One of the most important things we can do is migrate the user SIDs
14
If you already have auditing enabled in your domain you won’t get this error. You can simply choose to enable auditing from here. This is a requirement for SID migration.
15
For SID history migration we’re going to need some details.
16
If you wanted to migrate the associated user groups etc.
17
You can choose NOT to bring across certain properties as you wish. default is bring everything over.
18
Finished!
19
Woo. officially migrated a user to the new domain. But let’s check the log.
20
Log details. SID migration complete and Password also. Just what we wanted.
Get-Aduser -Identity testmigration -Properties Sidhistory
21
A quick check in PowerShell we can see the SID history has come across from the old domain and is associated with the new object

So now we’re happy with how our user migration has gone with our test user. We can logon to the new domain and still access our resources from the old domain thanks to our SID history migration.

Migration Automation

The next logical step is here is how can I automate this process? How can we make this easier with the tools we have.

Lucky for us we have PowerShell for that. Originally I stumbled across this function from PsCookieMonster (Thanks Warren!) on AD migration by creating a wrapper for the ADMT.exe tool.


<#
This is a PowerShell function wrapping the ADMT executable to abstract out migrating Active Directory users.
Read all the ADMT docs, and all the code and comments below before considering using this : )
The COM object was covered nicely by Jan Egil Ring:
http://blog.powershell.no/2010/08/04/automate-active-directory-migration-tool-using-windows-powershell/
Unfortunately, the COM object does not support Include files, a critical requirement for us.
Use this at your own risk. Seriously.
Minimal testing and handling for scenarios outside of my use cases. Danger!
No testing post sanitization. Danger!
Some items are mandatory in this function, but not outside it…
Some validateSets might not be complete
Lots of shortcuts. This function is not exposed to anyone, it runs in a delegated, constrained endpoint…
#>
Function Migrate-ADMTUserCLI {
<#
.SYNOPSIS
Migrate a user account using ADMT.exe
.DESCRIPTION
Migrate a user account using ADMT.exe
Requirements:
Must be run from 32 bit session
Must be run from a system with ADMT installed
Must be run with appropriate privileges
If migrating SIDHistory, must be run from a domain controller
IMPORTANT NOTES!
Minimal testing and handling for scenarios outside of my use cases, use at your own risk!
No testing post sanitization. Danger!
Some items are mandatory in this function, but not outside it…
Some validateSets might not be complete
.PARAMETER samaccountname
Source samaccountname
.PARAMETER TargetSamaccountname
Optional target samaccountname
.PARAMETER SourceDomain
Source domain that contains the user
From /{SD | SOURCEDOMAIN}:"source domain name"
.PARAMETER SourceDomainController
Optional. Source domain controller
If migrating passwords, pick the DC with the PES service
From [/{SDC | SOURCEDOMAINCONTROLLER}:"source domain controller name"]
.PARAMETER SourceOU
Optional. Source OU that contains the user
From [/{SO | SOURCEOU}:"source organizational unit name"]
.PARAMETER TargetDomain
Target domain to place the user
From /{TD | TARGETDOMAIN}:"target domain name"
.PARAMETER TargetDomainController
Optional target domain controller.
Pick the DC you're running ADMT on, if you're migrating SIDHistory.
From: [/{TDC | TARGETDOMAINCONTROLLER}:"target domain controller name"]
.PARAMETER TargetOU
Optional target OU. Won't matter if you're merging.
This takes a variation on canonical name. For example:
contoso.org/users/Migration would be written users/Migration
contoso.com/test/users would be written test/users
From: [/{TO | TARGETOU}:"target organizational unit name"]
.PARAMETER PasswordServer
Optional Domain controller on the source domain where PES is installed and running
From: [/{PS | PASSWORDSERVER}:"password export server name"]
.PARAMETER PasswordOption
How to handle passwords for the user account. Default is COMPLEX+NOTEXISTING.
COMPLEX = Create a new complex password
COPY = Copy existing password. REQUIRES PASSWORDSERVER / PES
+NOTEXISTING = Do not update password if it exists already
From: [/{PO | PASSWORDOPTION}:{<COMPLEX> | COPY}[+NOTEXISTING]]
.PARAMETER DisableOption
Optional handling for disable options. Default is TARGETSAMEASSOURCE
From: [/{DOT | DISABLEOPTION}:{[DISABLESOURCE+]ENABLETARGET | DISABLETARGET | <TARGETSAMEASSOURCE>}]
.PARAMETER MigrateGroups
Optional, whether to also migrate groups. Default is $False
From: [/{MGS | MIGRATEGROUPS}:{YES | <NO>}] no default value for intra-forest migration
.PARAMETER MigrateSids
Optional, whether to migrate SIDS. Default is True
Requires that you run this from a domain controller
Read the docs or see response from Cookie.Monster here for why:
https://social.technet.microsoft.com/Forums/en-US/da52ca43-2b90-4faa-beb9-92720abb3194/admt-32-install-on-dc-or-member-server?forum=winserverMigration
From: [/{MSS | MIGRATESIDS}:{YES | <NO>}]
.PARAMETER IncludeFile
Optional, whether to read from an include file. This overrides TargetSamAccountname
From: [/{F | INCLUDEFILE}:"include file"]
.PARAMETER ExcludeFile
Optional, exclude file
From: From [/{EF | EXCLUDEFILE}:"exclude file"]
.PARAMETER ConflictOptions
Optional, how to handle conflicts. Default is IGNORE
MERGE lets us merge SIDHistory into existing user.
From: [/{CO | CONFLICTOPTIONS}:{<IGNORE> | MERGE[+REMOVEUSERRIGHTS][+REMOVEMEMBERS][+MOVEMERGEDACCOUNTS]}]
.PARAMETER UserPropertiesToExclude
Optional. Properties to exclude. Pick * to avoid potentially pulling over the wrong property.
From: [/{UX | USERPROPERTIESTOEXCLUDE}:{* | "property1,property2,…"}]
.PARAMETER FixGroupMembership
Will fix group memberships when re-migrating. Default is True
From: [/{FGM | FIXGROUPMEMBERSHIP}:{<YES> | NO}]
.PARAMETER OptionFile
Option file to use. Not tested in my code, don't use this!
From: [/{O | OPTIONFILE}:"option file"]
.PARAMETER UpdatePreviouslyMigratedObjects
Whether to update previously migrated objects. Default is True
From: [/{UMO | UPDATEPREVIOUSLYMIGRATEDOBJECTS}:{YES | <NO>}]
.EXAMPLE
Migrate-ADMTUserCLI
-samaccountname wframet ` # Migrate wframet
-TargetSamaccountname wftest ` # into the wftest account
-SourceDomain contoso.org ` # from contoso.org
-SourceDomainController DC1.contoso.org ` # from this specific DC – I need this as I'm doing a Password migration
-TargetDomain contoso.com ` # to contoso.com
-TargetDomainController DC1.contoso.com ` # to this specific DC
-UserPropertiesToExclude * ` # Exclude all props. We only want to pull sid history…
-ConflictOptions MERGE ` # We know there is a conflict, so specify MERGE
-PasswordOption COPY # Sync my password from contoso.org. (forces me to change, work around this at your own risk…)
# I might run this if I want to migrate wframet to wftest specifically (i.e. new samaccountname)
# I specify a specific source DC that has the PES service on it
# I specify that I want to merge accounts
# I specify that I want to COPY password
.EXAMPLE
Migrate-ADMTUserCLI
-samaccountname UniqueUser ` # Migrate UniqueUser
-SourceDomain contoso.org ` # from contoso.org
-TargetDomain contoso.com ` # to contoso.com
-TargetDomainController DC1.contoso.com ` # to this specific DC
-TargetOU "test/Migration" ` # Create new user in the contoso.com/test/Migration OU
-UserPropertiesToExclude * ` # Exclude all props. We only want to pull sid history…
-verbose ` # Show what command runs…
# I might run this to migrate UniqueUser from contoso.org to contoso.com.
# If an existing account conflicts in contoso.com, this bombs out (IGNORE is default conflict option)
.FUNCTIONALITY
Active Directory
#>
[cmdletbinding(SupportsShouldProcess=$true, ConfirmImpact="High")]
param (
[parameter(Mandatory=$false,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[string]$samaccountname,
[string]$TargetSamaccountname,
[parameter(Mandatory=$True)]
#[validateset("contoso.com", "contoso.org")]
[string]$SourceDomain,
[parameter(Mandatory=$True)]
[string]$SourceDomainController,
[string]$SourceOU,
[parameter(Mandatory=$True)]
#[validateset("contoso.com", "contoso.org")]
[string]$TargetDomain,
[parameter(Mandatory=$True)]
[string]$TargetDomainController,
[string]$TargetOU,
#[validateset("DC1.contoso.com", "DC1.contoso.org")]
[string]$PasswordServer,
[validateset("COPY","COMPLEX","COPY+NOTEXISTING","COMPLEX+NOTEXISTING")]
[string]$PasswordOption = "COMPLEX+NOTEXISTING",
[validateset('DISABLESOURCE','DISABLESOURCE+ENABLETARGET','DISABLETARGET','TARGETSAMEASSOURCE')]
[string]$DisableOption,
[bool]$MigrateGroups = $False,
[bool]$MigrateSids = $True,
[validatescript({Test-Path $_ -ErrorAction Stop})]
[string]$IncludeFile,
[validatescript({Test-Path $_ -ErrorAction Stop})]
[string]$ExcludeFile,
[validateset(
"IGNORE", "MERGE",
"MERGE+REMOVEUSERRIGHTS", "MERGE+REMOVEMEMBERS", "MERGE+MOVEMERGEDACCOUNTS",
"MERGE+REMOVEUSERRIGHTS+REMOVEMEMBERS", "MERGE+REMOVEUSERRIGHTS+MOVEMERGEDACCOUNTS", "MERGE+REMOVEMEMBERS+MOVEMERGEDACCOUNTS",
"MERGE+REMOVEUSERRIGHTS+REMOVEMEMBERS+MOVEMERGEDACCOUNTS"
)]
[string]$ConflictOptions,
[string[]]$UserPropertiesToExclude,
[bool]$FixGroupMembership = $True,
[validatescript({Test-Path $_ -ErrorAction Stop})]
[string]$OptionFile,
[bool]$UpdatePreviouslyMigratedObjects = $True,
# Stuff I didn't implement:
# [/{IF | INTRAFOREST}:{YES | <NO>}]
# [/{PF | PASSWORDFILE}:"password file name"]
# [/{SEP | SOURCEEXPIRATION}:{<NONE> | 1 – 1095}] in days
# [/{TRP | TRANSLATEROAMINGPROFILE}:{YES | <NO>}]
# [/{UUR | UPDATEUSERRIGHTS}:{YES | <NO>}]
# [/{MSA | MIGRATESERVICEACCOUNTS}:{YES | <NO>}]
# [/{IX | INETORGPERSONPROPERTIESTOEXCLUDE}:{* | "property1,property2,…"}]
# [/{GX | GROUPPROPERTIESTOEXCLUDE}:{* | "property1,property2,…"}]
# /{N | INCLUDENAME} "name 1" "name 2"… |
# /{D | INCLUDEDOMAIN}[:RECURSE[+{<FLATTEN> | MAINTAIN}]]
# [/{EN | EXCLUDENAME} "name 1" "name 2"… |
[switch]$Force
)
Begin
{
$RejectAll = $false
$ConfirmAll = $false
#If you have known domain / DC with PES password service, consider enabling this
<#
$PasswordServerMap = @{
'contoso.org' = "DC1.contoso.org"
'constoso.com' = "DC1.constoso.com"
}
if($PasswordOption -match 'COPY' -and -not $PasswordServer)
{
$PasswordServer = $PasswordServerMap.$SourceDomain
if(-not $PasswordServer)
{
Throw "Could not find a Password server for PasswordOption '$PasswordOption', SourceDomain $SourceDomain"
}
}
#>
}
Process
{
#Start building up parameters
$CLIParams = New-Object System.Collections.Arraylist
[void]$CLIParams.AddRange(@(
"/SOURCEDOMAIN:`"$SourceDomain`"",
"/SOURCEDOMAINCONTROLLER:`"$SourceDomainController`"",
"/TARGETDOMAIN:`"$TargetDomain`"",
"/TARGETDOMAINCONTROLLER:`"$TargetDomainController`""
))
if($TargetOU) {[void]$CLIParams.Add("/TARGETOU:`"$TargetOU`"")}
if($SourceOU) { [void]$CLIParams.Add("/SOURCEOU:`"$SourceOU`"") }
if($PasswordServer) { [void]$CLIParams.Add("/PASSWORDSERVER:`"$PasswordServer`"") }
if($PasswordOption) { [void]$CLIParams.Add("/PASSWORDOPTION:$PasswordOption") }
if($DisableOption) { [void]$CLIParams.Add("/DISABLEOPTION:$DisableOption") }
#Translate bool to yes/no
if($MigrateGroups) { [void]$CLIParams.Add("/MIGRATEGROUPS:YES") }
else { [void]$CLIParams.Add("/MIGRATEGROUPS:NO") }
if($MigrateSids) { [void]$CLIParams.Add("/MIGRATESIDS:YES") }
else { [void]$CLIParams.Add("/MIGRATESIDS:NO") }
if($FixGroupMembership) { [void]$CLIParams.Add("/FIXGROUPMEMBERSHIP:YES") }
else { [void]$CLIParams.Add("/FIXGROUPMEMBERSHIP:NO") }
if($UpdatePreviouslyMigratedObjects) { [void]$CLIParams.Add("/UPDATEPREVIOUSLYMIGRATEDOBJECTS:YES") }
else { [void]$CLIParams.Add("/UPDATEPREVIOUSLYMIGRATEDOBJECTS:NO") }
#Translate string array to string, or single value, no quotes
if($UserPropertiesToExclude)
{
if($UserPropertiesToExclude -contains "*")
{
[void]$CLIParams.Add("/USERPROPERTIESTOEXCLUDE:*")
}
elseif($UserPropertiesToExclude -is [string[]])
{
[void]$CLIParams.Add("/USERPROPERTIESTOEXCLUDE:`"$($USERPROPERTIESTOEXCLUDE -join ",")`"")
}
}
if($OptionFile) { [void]$CLIParams.Add("/OPTIONFILE:`"$OptionFile`"") }
if($ExcludeFile) { [void]$CLIParams.Add("/EXCLUDEFILE:`"$ExcludeFile`"") }
if($IncludeFile)
{
[void]$CLIParams.Add("/INCLUDEFILE:`"$IncludeFile`"")
}
elseif($TargetSamaccountname)
{
# We hard coded this in with the assumption that if someone specified a targetsamaccountname, the intent was a merge
# We don't use any of the other conflict options, but you might, so… commented this out.
#$ConflictOptions = "MERGE"
$File = "$env:USERPROFILE\$samaccountname-to-$TargetSamaccountname-from-$SourceDomain.csv"
<#
Important notes. From what I've seen:
Without target RDN, the CN is renamed…
The UPN is also renamed…
Example format we construct.
Sourcename,TargetRDN,TargetSAM
cmonster,"CN=Monster\, Cookie",cmonster
#>
Try
{
# From https://gallery.technet.microsoft.com/scriptcenter/Get-ADSIObject-Portable-ae7f9184
$ExistingDN = @( Get-ADSIObject -samAccountName $TargetSamaccountname -Path $TargetDomainController -Property distinguishedname -ErrorAction stop | Select -ExpandProperty distinguishedname -ErrorAction SilentlyContinue )
If($ExistingDN.count -gt 1)
{
Throw "Error, expected 0 or 1 results, '$($ExistingDN.count)' returned:`n$($ExistingDN | Out-String)"
}
$ExistingDN = ( $ExistingDN[0] -split '(?<!\\),' )[0]
}
Catch
{
Throw "Error finding existing target account:`n$_"
}
Try
{
if($ExistingDN.count -eq 1)
{
$IncludeProps = @{
Sourcename = $samaccountname
TargetRDN = $ExistingDN
TargetSAM = $TargetSamaccountname
}
}
else #count=0
{
$IncludeProps = @{
Sourcename = $samaccountname
TargetSAM = $TargetSamaccountname
}
}
$TempObject = New-Object -TypeName PSObject -Property $IncludeProps | Select Sourcename, TargetRDN, TargetSAM
$TempObject | Export-Csv -NoTypeInformation -path $File -force -ErrorAction stop
Write-Verbose "Created IncludeFile $File with data:`n$($TempObject | ft -AutoSize | out-string)"
Start-Sleep -Milliseconds 500
}
Catch
{
Throw "Error creating csv for include file: $_"
}
[void]$CLIParams.Add("/INCLUDEFILE:`"$file`"")
}
elseif($samaccountname)
{
#We aren't using an includefile or a target sam. Just use the name
[void]$CLIParams.Add("/INCLUDENAME `"$samaccountname`"")
}
#At this point, we should know conflict options…
if($ConflictOptions) { [void]$CLIParams.Add("/CONFLICTOPTIONS:$ConflictOptions")}
Write-Verbose "`n$($CLIParams | Out-String)"
if($Force -or $PSCmdlet.ShouldProcess( "Processed the user '$samaccountname'",
"Process the user '$samaccountname'?",
"Processing user" ))
{
if($Force -Or $PSCmdlet.ShouldContinue("Are you REALLY sure you want to process `n$($CLIParams | Out-String)", "Processing '$samaccountname'", [ref]$ConfirmAll, [ref]$RejectAll)) {
$Script = "admt.exe USER $CLIParams"
Write-Verbose "Final command:`n$($script | Out-String)"
#Quick and dirty. Really shouldn't be using this… Presumably, you're going to be running this in a tightly controlled environment : )
Invoke-Expression $Script
}
}
}
}
# Quick and dirty ADSI query function (no RSAT needed).
# We use this to get the target DN when merging with an existing user.
# This should really be offloaded to another function.
# We don't fix the UPN, which ADMT seems to enjoy changing as well
function Get-ADSIObject {
<#
.SYNOPSIS
Get AD object (user, group, etc.) via ADSI.
.DESCRIPTION
Get AD object (user, group, etc.) via ADSI.
Invoke a specify an LDAP Query, or search based on samaccountname and/or objectcategory
.FUNCTIONALITY
Active Directory
.PARAMETER samAccountName
Specific samaccountname to filter on
.PARAMETER ObjectCategory
Specific objectCategory to filter on
.PARAMETER Query
LDAP filter to invoke
.PARAMETER Path
LDAP Path. e.g. contoso.com, DomainController1
LDAP:// is prepended when omitted
.PARAMETER Property
Specific properties to query for
.PARAMETER Limit
If specified, limit results to this size
.PARAMETER SearchRoot
If specified, narrow search to this root
.PARAMETER Credential
Credential to use for query
If specified, the Path parameter must be specified as well.
.PARAMETER As
SearchResult = results directly from DirectorySearcher
DirectoryEntry = Invoke GetDirectoryEntry against each DirectorySearcher object returned
PSObject (Default) = Create a PSObject with expected properties and types
.EXAMPLE
Get-ADSIObject jdoe
# Find an AD object with the samaccountname jdoe
.EXAMPLE
Get-ADSIObject -Query "(&(objectCategory=Group)(samaccountname=domain admins))"
# Find an AD object meeting the specified criteria
.EXAMPLE
Get-ADSIObject -Query "(objectCategory=Group)" -Path contoso.com
# List all groups at the root of contoso.com
.EXAMPLE
Echo jdoe, cmonster | Get-ADSIObject jdoe -property mail | Select -expandproperty mail
# Find an AD object for a few users, extract the mail property only
.EXAMPLE
$DirectoryEntry = Get-ADSIObject TESTUSER -as DirectoryEntry
$DirectoryEntry.put(‘Title’,’Test’)
$DirectoryEntry.setinfo()
#Get the AD object for TESTUSER in a usable form (DirectoryEntry), set the title attribute to Test, and make the change.
#>
[cmdletbinding(DefaultParameterSetName='SAM')]
Param(
[Parameter( Position=0,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true,
ParameterSetName='SAM')]
[string[]]$samAccountName = "*",
[Parameter( Position=1,
ParameterSetName='SAM')]
[string[]]$ObjectCategory = "*",
[Parameter( ParameterSetName='Query',
Mandatory = $true )]
[string]$Query = $null,
[string]$Path = $Null,
[string[]]$Property = $Null,
[int]$Limit,
[string]$SearchRoot,
[System.Management.Automation.PSCredential]$Credential,
[validateset("PSObject","DirectoryEntry","SearchResult")]
[string]$As = "PSObject"
)
Begin
{
#Define parameters for creating the object
$Params = @{
TypeName = "System.DirectoryServices.DirectoryEntry"
ErrorAction = "Stop"
}
#If we have an LDAP path, add it in.
if($Path){
if($Path -notlike "^LDAP")
{
$Path = "LDAP://$Path"
}
$Params.ArgumentList = @($Path)
#if we have a credential, add it in
if($Credential)
{
$Params.ArgumentList += $Credential.UserName
$Params.ArgumentList += $Credential.GetNetworkCredential().Password
}
}
elseif($Credential)
{
Throw "Using the Credential parameter requires a valid Path parameter"
}
#Create the domain entry for search root
Try
{
Write-Verbose "Bound parameters:`n$($PSBoundParameters | Format-List | Out-String )`nCreating DirectoryEntry with parameters:`n$($Params | Out-String)"
$DomainEntry = New-Object @Params
}
Catch
{
Throw "Could not establish DirectoryEntry: $_"
}
$DomainName = $DomainEntry.name
#Set up the searcher
$Searcher = New-Object -TypeName System.DirectoryServices.DirectorySearcher
$Searcher.PageSize = 1000
$Searcher.SearchRoot = $DomainEntry
if($Limit)
{
$Searcher.SizeLimit = $limit
}
if($Property)
{
foreach($Prop in $Property)
{
$Searcher.PropertiesToLoad.Add($Prop) | Out-Null
}
}
if($SearchRoot)
{
if($SearchRoot -notlike "^LDAP")
{
$SearchRoot = "LDAP://$SearchRoot"
}
$Searcher.SearchRoot = [adsi]$SearchRoot
}
#Define a function to get ADSI results from a specific query
Function Get-ADSIResult
{
[cmdletbinding()]
param(
[string[]]$Property = $Null,
[string]$Query,
[string]$As,
$Searcher
)
#Invoke the query
$Results = $null
$Searcher.Filter = $Query
$Results = $Searcher.FindAll()
#If SearchResult, just spit out the results.
if($As -eq "SearchResult")
{
$Results
}
#If DirectoryEntry, invoke GetDirectoryEntry
elseif($As -eq "DirectoryEntry")
{
$Results | ForEach-Object { $_.GetDirectoryEntry() }
}
#Otherwise, get properties from the object
else
{
$Results | ForEach-Object {
#Get the keys. They aren't an array, so split them up, remove empty, and trim just in case I screwed something up…
$object = $_
#cast to array of strings or else PS2 breaks when we select down the line
[string[]]$properties = ($object.properties.PropertyNames) -split "`r|`n" | Where-Object { $_ } | ForEach-Object { $_.Trim() }
#Filter properties if desired
if($Property)
{
$properties = $properties | Where-Object {$Property -Contains $_}
}
#Build up an object to output. Loop through each property, extract from ResultPropertyValueCollection
#Create the object, PS2 compatibility. can't just pipe to select, props need to exist
$hash = @{}
foreach($prop in $properties)
{
$hash.$prop = $null
}
$Temp = New-Object -TypeName PSObject -Property $hash | Select -Property $properties
foreach($Prop in $properties)
{
Try
{
$Temp.$Prop = foreach($item in $object.properties.$prop)
{
$item
}
}
Catch
{
Write-Warning "Could not get property '$Prop': $_"
}
}
$Temp
}
}
}
}
Process
{
#Set up the query as defined, or look for a samaccountname. Probably a cleaner way to do this…
if($PsCmdlet.ParameterSetName -eq 'Query'){
Write-Verbose "Working on Query '$Query'"
Get-ADSIResult -Searcher $Searcher -Property $Property -Query $Query -As $As
}
else
{
foreach($AccountName in $samAccountName)
{
#Build up the LDAP query…
$QueryArray = @( "(samAccountName=$AccountName)" )
if($ObjectCategory)
{
[string]$TempString = ( $ObjectCategory | ForEach-Object {"(objectCategory=$_)"} ) -join ""
$QueryArray += "(|$TempString)"
}
$Query = "(&$($QueryArray -join ''))"
Write-Verbose "Working on built Query '$Query'"
Get-ADSIResult -Searcher $Searcher -Property $Property -Query $Query -As $As
}
}
}
End
{
$Searcher = $null
$DomainEntry = $null
}
}
<#
LICENSE
The MIT License (MIT)
Copyright (c) 2015 ramblingcookiemonster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
#>

I figured I would have a look deeper into it and play around with it. Let’s delete our testmigration user and migrate him again with PowerShell.

Let’s take a look at how we can use this function at a basic level to start out (PowerShell as Administrator):

1
A very basic migration without Password Migration
2
Result of verbose output and completion

After checking the SID history of the migrated user again it all looks well. Let’s make this more production suitable.

Migrate-ADMTUserCLI -samaccountname testmigration `
-SourceDomain olddomain.corp.com `
-TargetDomain newdomain.corp.com `
-Verbose `
-SourceDomainController 2008R2-DC.olddomain.corp.com `
-TargetDomainController 2012R2-DC.newdomain.corp.com `
-Force `
-PasswordServer 2008R2-DC.olddomain.corp.com `
-PasswordOption COPY `
-ConflictOptions IGNORE `
-MigrateSids $true `
-MigrateGroups $false

Boom. Now we’re able to do all of those silly clicks in the GUI all in Powerhell. The only thing I’ve left off this code is the Target/Source OU. This is a great time to bring up those CSV files you created in the planning stage of the AD migration since now they’re going to save you tons of time.

Copy your CSV file’s of your staged groups over to the target domain and let’s get started!

$Users = Import-csv 'C:\Migration\DevOps_Users.csv'
#foreach
Foreach ($User in $users.SamAccountName){
Migrate-ADMTUserCLI -samaccountname $user `
-SourceDomain 'olddomain.corp.com' `
-TargetDomain 'newdomain.corp.com' `
-TargetOU 'IT/DevOps' `
-Verbose `
-SourceDomainController '2008R2-DC.olddomain.corp.com' `
-TargetDomainController '2012R2-DC.newdomain.corp.com' `
-Force `
-PasswordServer '2008R2-DC.olddomain.corp.com' `
-PasswordOption COPY `
-ConflictOptions IGNORE `
-MigrateSids $true `
-MigrateGroups $false

Now you can just sit back and watch the users migrate!

In this example above I’ve used a few hardcoded values. These can be as flexible as you make your CSV. Remember ground work before hand will save you a ton of time later on.

The next steps here would be however you want to handle it, You’re migrating users now and we’ve come a long way. Just make sure when you’re finished with this whole process that you remove SQL server and any accounts associated with this process. And of course, remove the GUI from the DC.

Good luck to all of you whom are undertaking this task and please let me know if you have any questions as always!

Edit: it’s good to mention that when you are doing migrating with SIDhistory you will want to disable to users in the olddomain as you bring them across to avoid any sort of issues with doubled-up SIDs and to also adhere to security best practises.

2 thoughts on “Active Directory Forest Migration

Leave a comment