Category Archives: PowerShell

Exchange Online: Dynamically add mailbox permissions through groups

Managing shared and resource mailbox permissions can be more or less a pain depending on how you do it. Adding users directly as delegates to the shared mailboxes gives you a bit more administrative overhead, but makes it possible to utilize the AutoMapping feature that automatically adds the additional mailboxes to the end users Outlook client. If you instead add mail-enabled security groups to the delegate list, you get less administrative overhead, but not the AutoMapping feature.

Even though this is a really “old” problem, I get questions around it almost every week. Therefore I wanted to share a solution with you that gives you an example on how to solve it still using groups. A script that will use Mail-Enabled Security Groups/Distribution Groups to dynamically populate the delegate list of a shared mailbox.

GETTING STARTED
In order to get started, you need to create one “Shadow Group” per mailbox who’s delegation properties you want to manage automatically. To make it as easy as possible, I’m using a prefix to identify the groups with the mailboxes, like Prefix-MailboxName. In my example below, the prefix is SM-. See the screenshot below for the correlation between them.
2014-11-16 11-39-45
It could of course be possible to use another attribute on the groups to store the mailbox name, but I thought this was an easier approach.

RUNNING THE SCRIPT
After editing the credentials in the Connect-ExchangeOnline function, you simply run the script as below:

.\SharedMailboxViaGroups.ps1 -Prefix 'SM-'

2014-11-16 12-10-56
As seen in the screenshot above it will process adds and removals of permissions for all mailboxes/groups in scope. Please note that both FullAccess and SendAs-permissions are added for each delegate.
SharedMailboxViaGroups.ps1

<#
.SYNOPSIS
    The script will automatically assign mailbox and recipient permissions on shared mailboxes based on groups.
.EXAMPLE
   .\SharedMailboxViaGroups.ps1 -Prefix 'SM-'
.PARAMETER Prefix
    Prefix of the groups that will manage permissions on the shared mailboxes.
.NOTES
    File Name: SharedMailboxViaGroups.ps1
    Author   : Johan Dahlbom, johan[at]dahlbom.eu
    Blog     : 365lab.net
    The script are provided “AS IS” with no guarantees, no warranties, and they confer no rights.
    Requires PowerShell Version 3.0!
#>
param (
  [string]$Prefix = 'SM-'
)

function Connect-ExchangeOnline {
  param(
    [string]$Username = 'admin@tenant.onmicrosoft.com',
    [string]$Password = 'password'
  )
  #Clean up existing PowerShell Sessions
  Get-PSSession | Remove-PSSession
  $CloudCred = New-Object –TypeName System.Management.Automation.PSCredential –ArgumentList $Username, (ConvertTo-SecureString $Password -AsPlainText -Force)
 
  #Connect to Exchange Online
  $Session = New-PSSession –ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $CloudCred -Authentication Basic -AllowRedirection
  $Commands = @("Add-MailboxPermission","Add-RecipientPermission","Remove-RecipientPermission","Remove-MailboxPermission","Get-MailboxPermission","Get-User","Get-DistributionGroupMember","Get-DistributionGroup","Get-Mailbox")
  Import-PSSession -Session $Session -Prefix "Cloud" -DisableNameChecking:$true -AllowClobber:$true -CommandName $Commands | Out-Null
}
function Add-JDMailboxPermission {
  param(
    [string]$Identity,
    [string]$SharedMailboxName
  )
  try {
    Add-CloudMailboxPermission -Identity $SharedMailboxName -User $Identity -AccessRights FullAccess -ErrorAction stop | Out-Null
    Add-CloudRecipientPermission -Identity $SharedMailboxName -Trustee $Identity -AccessRights SendAs -Confirm:$False -ErrorAction stop | Out-Null
    Write-Output "INFO: Successfully added $Identity to $SharedMailboxName"
  } catch {
    Write-Warning "Cannot add $Identity to $SharedMailboxName`r`n$_"
  }
}
function Remove-JDMailboxPermission {
  param(
    [string]$Identity,
    [string]$SharedMailboxName
  )
  try {
    Remove-CloudMailboxPermission -Identity $SharedMailboxName -User $Identity -AccessRights FullAccess -Confirm:$False -ErrorAction stop -WarningAction ignore | Out-Null
    Remove-CloudRecipientPermission -Identity $SharedMailboxName -Trustee $Identity -AccessRights SendAs -Confirm:$False -ErrorAction stop -WarningAction ignore  | Out-Null
    Write-Output "INFO: Successfully removed $Identity from $SharedMailboxName"
  } catch {
    Write-Warning "Cannot remove $Identity from $SharedMailboxName`r`n$_"
  }
}
function Sync-EXOResourceGroup {
  [CmdletBinding(SupportsShouldProcess=$true)]
  param(
    [string]$Prefix = 'SM-'
  )
  #Get All groups to process mailboxes for
  $MasterGroups = Get-CloudDistributionGroup -ResultSize Unlimited -Identity "$Prefix*"
  foreach ($Group in $MasterGroups) {
    #Remove prefix to get the mailbox name
    $MbxName = $Group.Name.Replace("$Prefix",'')
    $SharedMailboxName =  (Get-CloudMailbox -Identity $MbxName -ErrorAction ignore -WarningAction ignore).WindowsLiveID
    if ($SharedMailboxName) { 
      Write-Verbose -Message "Processing group $($Group.Name) and mailbox $SharedMailboxName"
      #Get all users with explicit permissions on the mailbox
      $SharedMailboxDelegates = Get-CloudMailboxPermission -Identity $SharedMailboxName -ErrorAction Stop -ResultSize Unlimited | Where-Object {$_.IsInherited -eq $false -and $_.User -ne "NT AUTHORITY\SELF" -and $_.User -notmatch 'S-\d-\d-\d+-\d+-\d+-\d+-\w+' -and $_.User -notlike "$Prefix*"} |  Select-Object @{Name="User";Expression={(Get-CloudUser -identity $_.User).WindowsLiveID }}
      #Get all group members
      $SharedMailboxMembers = Get-CloudDistributionGroupMember -Identity $Group.Identity -ResultSize Unlimited
      #Remove users if group is empty
      if (-not($SharedMailboxMembers) -and $SharedMailboxDelegates) {
        Write-Warning "The group $Group is empty, will remove explicit permissions from $SharedMailboxName"
        foreach ($user in $SharedMailboxDelegates.User) {
          Remove-JDMailboxPermission -Identity $user -SharedMailboxName $SharedMailboxName
        }
        #Add users if no permissions are present
      } elseif (-not($SharedMailboxDelegates)) {
        foreach ($user in $SharedMailboxMembers.WindowsLiveID) {
          Add-JDMailboxPermission -Identity $user -SharedMailboxName $SharedMailboxName
        }
        #Process removals and adds
      } else {
        #Compare the group with the users that have actual access
        $Users = Compare-Object -ReferenceObject $SharedMailboxDelegates.User -DifferenceObject $SharedMailboxMembers.WindowsLiveID 
         
        #Add users that are members of the group but do not have access to the shared mailbox
        foreach ($user in ($users | Where-Object {$_.SideIndicator -eq "=>"})) {
          Add-JDMailboxPermission -Identity $user.InputObject -SharedMailboxName $SharedMailboxName
        }
        #Remove users that have access to the shared mailbox but are not members of the group
        foreach ($user in ($users | Where-Object {$_.SideIndicator -eq "<="})) {
          Remove-JDMailboxPermission -Identity $user.InputObject -SharedMailboxName $SharedMailboxName
        }
      }
    } else {
      Write-Warning "Could not find the mailbox $MbxName"
    }
  }
}
#Connect to Exchange Online
Connect-ExchangeOnline
#Start Processing groups and mailboxes
Sync-EXOResourceGroup -Prefix $Prefix -Verbose

SUMMARY
A pretty simple solution on a very common problem in most environments where you have shared mailboxes. Let me know if you have feature requests or if something is not working as intended!

Enjoy!

/Johan

Advertisements

Office 365: Assign licenses based on groups using PowerShell – Advanced version

Important note: The end of an era with licensing scripts is near… and the beginning of a new one with Azure AD Group Based Licensing is here. Group Based Licensing is now in preview and currently requires a paid Azure AD Subscription. Try it out and give Microsoft your feedback on how they can make it even better! 

The never ending story about Office 365 licensing continues… This time it’s an extension of my script to assign licenses based on groups, with additional functionality to remove and change licenses for users.
I’ve come across scenarios where this have been a requirement a couple of times, and wanted to see how much work that was required to get the job done. 🙂

If you just want to assign licenses for users based on groups, plain and simple, this is not the script for you…

Running the script
The script is tested in a tenant with two different license types (E1 and E3). Therefore, the functionality has been verified against that, so if you have three or four different licenses to assign in your tenant, you have to do your own testing! 🙂

The following functions are included in the script:

  • Assignment of licenses for new users based on groups/licenseSKU’s in the $licenses hashtable
  • Switch of licensetype if a user is moved from one group to another
  • Removal of license if the user no longer is a member in any of the license assignment groups

LicenseFix

IMPORTANT: Since the script actually will remove licenses for users that are not in any of the groups, you have to make sure that you populate the license assignment groups prior to first time running the script.

Apart from the above, the script requirements and setup details are the same as in this post.

LicenseO365Users.ps1

<#   .SYNOPSIS      Script that assigns Office 365 licenses based on Group membership in WAAD. .DESCRIPTION     The script assigns of licenses for new users based on groups/licenseSKUs in the $licenses hashtable.     It switch licensetype if a user is moved from one group to Another.     It removes the license if the user no longer is a member in any of the license assignment Groups.     Updated 2015-03-25 to support multiple skus for each user.     The script REQUIRES PowerShell 3.0 or later! .NOTES      Author: Johan Dahlbom      Blog: 365lab.net      Email: johan[at]dahlbom.eu      The script are provided “AS IS” with no guarantees, no warranties, and they confer no rights.      #>
#Import Required PowerShell Modules
Import-Module MSOnline

#Office 365 Admin Credentials
$CloudUsername = 'admin@365lab.net'
$CloudPassword = ConvertTo-SecureString 'Password' -AsPlainText -Force
$CloudCred = New-Object System.Management.Automation.PSCredential $CloudUsername, $CloudPassword

#Connect to Office 365
Connect-MsolService -Credential $CloudCred

$Licenses = @{
                 'E1' = @{
                          LicenseSKU = 'mstlabs:STANDARDPACK'
                          Group = 'E1_Users'
                        }                        

                 'E3' = @{
                          LicenseSKU = 'mstlabs:ENTERPRISEPACK'
                          Group = 'E3_Users'
                        }
            }

$UsageLocation = 'SE'

#Get all currently licensed users and put them in a custom object
$LicensedUserDetails = Get-MsolUser -All | Where-Object {$_.IsLicensed -eq 'True'} | ForEach-Object {
 [pscustomobject]@{
            UserPrincipalName = $_.UserPrincipalName
            License = $_.Licenses.AccountSkuId
            }
 }

#Create array for users to change or delete
$UsersToChangeOrDelete = @()

foreach ($license in $Licenses.Keys) {

  #Get current group name and ObjectID from Hashtable
  $GroupName = $Licenses[$license].Group
  $GroupID = (Get-MsolGroup -All | Where-Object {$_.DisplayName -eq $GroupName}).ObjectId
  $AccountSKU = Get-MsolAccountSku | Where-Object {$_.AccountSKUID -eq $Licenses[$license].LicenseSKU}

  Write-Output "Checking for unlicensed $license users in group $GroupName with ObjectGuid $GroupID..."
  #Get all members of the group in current scope
  $GroupMembers = (Get-MsolGroupMember -GroupObjectId $GroupID -All).EmailAddress
  #Get all already licensed users in current scope
  $ActiveUsers = ($LicensedUserDetails | Where-Object {$_.License -eq $licenses[$license].LicenseSKU}).UserPrincipalName
  $UsersToHandle = ''

    if ($GroupMembers) {
        if ($ActiveUsers) {
            #Compare $Groupmembers and $Activeusers
            #Users which are in the group but not licensed, will be added
            #Users licensed, but not, will be evaluated for deletion or change of license
            $UsersToHandle = Compare-Object -ReferenceObject $GroupMembers -DifferenceObject $ActiveUsers -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
            $UsersToAdd = ($UsersToHandle | Where-Object {$_.SideIndicator -eq '<='}).InputObject             $UsersToChangeOrDelete += ($UsersToHandle | Where-Object {$_.SideIndicator -eq '=>'}).InputObject
        } else {
            #No licenses currently assigned for the license in scope, assign licenses to all group members.
            $UsersToAdd = $GroupMembers
        }

    } else {
      Write-Warning  "Group $GroupName is empty - will process removal or move of all users with license $($AccountSKU.AccountSkuId)"
      #If no users are a member in the group, add them for deletion or change of license.
      $UsersToChangeOrDelete += $ActiveUsers
    }

  #Check the amount of licenses left...
  if ($AccountSKU.ActiveUnits - $AccountSKU.consumedunits -lt $UsersToAdd.Count) {
        Write-Warning 'Not enough licenses for all users, please remove user licenses or buy more licenses'
  }

     foreach ($User in $UsersToAdd){

        #Process all users for license assignment, if not already licensed with the SKU in order.
          if ((Get-MsolUser -UserPrincipalName $User).Licenses.AccountSkuId -notcontains $AccountSku.AccountSkuId) {
            try {
                  #Assign UsageLocation and License.
                  Set-MsolUser -UserPrincipalName $User -UsageLocation $UsageLocation -ErrorAction Stop -WarningAction Stop
                  Set-MsolUserLicense -UserPrincipalName $User -AddLicenses $AccountSKU.AccountSkuId -ErrorAction Stop -WarningAction Stop
                  Write-Output "SUCCESS: Licensed $User with $license"
            } catch {
                  Write-Warning "Error when licensing $User"

            }

          }
     }
}

#Process users for change or deletion
if ($UsersToChangeOrDelete -ne $null) {
        foreach ($User in $UsersToChangeOrDelete) {
          if ($user -ne $null) {

            #Fetch users old license for later usage
            $OldLicense = ($LicensedUserDetails | Where-Object {$_.UserPrincipalName -eq $User}).License

             #Loop through to check if the user group assignment has been changed, and put the old and the new license in a custom object.
             #Only one license group per user is currently supported.
             $ChangeLicense = $Licenses.Keys | ForEach-Object {
                  $GroupName = $Licenses[$_].Group
                  if (Get-MsolGroupMember -All -GroupObjectId (Get-MsolGroup -All | Where-Object {$_.DisplayName -eq $GroupName}).ObjectId | Where-Object {$_.EmailAddress -eq $User}) {
                     [pscustomobject]@{
                        OldLicense = $OldLicense
                        NewLicense = $Licenses[$_].LicenseSKU
                     }
                  } 

              }

              if ($ChangeLicense) {
                    #The user were assigned to another group, switch license to the new one.
                    try {
                          Set-MsolUserLicense -UserPrincipalName $User -RemoveLicenses $ChangeLicense.OldLicense -AddLicenses $ChangeLicense.NewLicense -ErrorAction Stop -WarningAction Stop
                          Write-Output "SUCCESS: Changed license for user $User from $($ChangeLicense.OldLicense) to $($ChangeLicense.NewLicense)"
                    } catch {
                          Write-Warning "Error when changing license on $User`r`n$_"
                    }

              } else {  

                    #The user is no longer a member of any license group, remove license
                    Write-Warning "$User is not a member of any group, license will be removed... "
                    try {
                          Set-MsolUserLicense -UserPrincipalName $User -RemoveLicenses $OldLicense -ErrorAction Stop -WarningAction Stop
                          Write-Output "SUCCESS: Removed $OldLicense for $User"
                    } catch {
                          Write-Warning "Error when removing license on user`r`n$_"
                    }
              }
         }
    }
}

Hope this helps you if having this scenario, please let me know if you have features requests or other things that can improve the script!

/Johan

PowerShell GUI application to set thumbnailPhoto

A while ago I started experimenting with writing GUI applications in PowerShell. This script that updates the thumbnailPhoto attribute of a user was my first attempt, and since it works quite well I thought that I might share it with you.

The thumbnailPhoto attribute is used more and more, and it is now supported by Outlook, SharePoint and Lync. It is also supported by Office 365, and when using DirSync the data is synced to Windows Azure Active Directory.

This script uses Windows Presentation Foundation (WPF) which is part of .Net 3.0. The window form is easily built with Microsoft Visual Studio Express 2013 for Windows Desktop and can be saved as a XAML document, which we can import in our PowerShell script.

Data are saved and retrieved from Active Directory using ADSI, which means no additional PowerShell modules must be installed. The only requirements are PowerShell 2.0 or newer with .Net Framework 3.0. If running PowerShell version 2.0 you also need the -STA parameter to powershell.exe.

The application itself is very simple, just type the username in the search box, and browse for a photo. When clicking Apply the image data is saved to Active Directory. By default all users has permission to change their own thumbnailPhoto. To change other users, permissions needs to be delegated.

There is a limit in picture size, maximum size is 100 kB, but since Office 365 cannot handle that large image I have set the limit in the script to 10 kB. Also recommended resolution is 96×96 pixels.

thumbnailphotoguiapp

Here’s the full script, please note that you need a valid path to the .xaml document.

Set-ThumbnailPhotoGUI-mainform.xaml

<Window x:Class="MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Thumbnail photo changer app" Height="350" Width="525" ResizeMode="NoResize">
    <Grid x:Name="list1" Margin="0,0,0,2">
        <Label Content="Username:" HorizontalAlignment="Left" Width="105" Margin="20,23,0,250" />
        <TextBox x:Name="txtSearch" Margin="138,27,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" Width="122"/>
        <Button x:Name="btnSearch" Content="Search" HorizontalAlignment="Left" VerticalAlignment="Top" Width="50" Margin="271,27,0,0"/>
        <Label Content="Surname:" HorizontalAlignment="Left" Height="30" Margin="20,92,0,0" VerticalAlignment="Top" Width="92"/>
        <Label x:Name="lblSn" Content="" Height="30" VerticalAlignment="Top" HorizontalAlignment="Left" Width="315" Margin="134,92,0,0"/>
        <Label Content="UserPrincipalName:" HorizontalAlignment="Left" Height="30" Margin="20,123,0,0" VerticalAlignment="Top" Width="115"/>
        <Label x:Name="lblUPN" Content="" Height="30" VerticalAlignment="Top" HorizontalAlignment="Left" Width="346" Margin="134,123,0,0"/>
        <Label Content="Givenname:" HorizontalAlignment="Left" Height="30" Margin="20,61,0,0" VerticalAlignment="Top" Width="92"/>
        <Label x:Name="lblGivenName" Content="" Height="30" VerticalAlignment="Top" HorizontalAlignment="Left" Width="315" Margin="134,61,0,0"/>
        <Image x:Name="imgThumb" HorizontalAlignment="Left" Height="96" Margin="29,203,0,0" VerticalAlignment="Top" Width="96"/>
        <Button Name="btnImg" Content="Change photo..." HorizontalAlignment="Left" Height="28" Margin="149,267,0,0" VerticalAlignment="Top" Width="111" IsEnabled="False"/>
        <Label Content="DistinguishedName:" HorizontalAlignment="Left" Height="30" Margin="20,156,0,0" VerticalAlignment="Top" Width="126"/>
        <Label x:Name="lblDN" Content="" Height="30" VerticalAlignment="Top" HorizontalAlignment="Left" Width="373" Margin="134,156,0,0"/>
        <Button x:Name="btnApply" Content="Apply" HorizontalAlignment="Left" Height="28" Margin="278,267,0,0" VerticalAlignment="Top" Width="111" IsEnabled="False"/>
    </Grid>
</Window>

Set-ThumbnailPhotoGUI.ps1

if ([threading.thread]::CurrentThread.ApartmentState.ToString() -eq 'MTA') {
    Write-Error "This script requires PowerShell to run in Single-Threaded Apartment. Please run powershell.exe with parameter -STA"
    Exit
}

Add-Type -AssemblyName PresentationFramework 
[xml]$XAML = Get-Content "Set-ThumbnailPhotoGUI-mainform.xaml"
$XAML.Window.RemoveAttribute("x:Class")
 
$global:userinfo = @{
    dn = $null
    photo = [byte[]]$null
}
 
function Invoke-FileBrowser {
    param([string]$Title,[string]$Directory,[string]$Filter="jpeg (*.jpg)|*.jpg")
    [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null
    $FileBrowser = New-Object System.Windows.Forms.OpenFileDialog
    $FileBrowser.InitialDirectory = $Directory
    $FileBrowser.Filter = $Filter
    $FileBrowser.Title = $Title
    $Show = $FileBrowser.ShowDialog()
    If ($Show -eq "OK") { Return $FileBrowser.FileName }
}
 
function Get-LDAPUsers($sam) {
    $activeDomain = New-Object DirectoryServices.DirectoryEntry
    $domain = $activeDomain.distinguishedName
    $searcher = [System.DirectoryServices.DirectorySearcher]"[ADSI]LDAP://$domain"
    $searcher.filter = "(&(samaccountname=$sam)(objectClass=user)(objectClass=person))"
    $result = $searcher.findall()
     
    if ($result.count -gt 1) {
        Write-Warning "More than one user found. Please refine your search."
    } elseif ($result.count -eq 1) {
        $result = $searcher.findone().getDirectoryEntry()
        [pscustomobject]@{
            UserPrincipalName = $result.userprincipalname[0]
            DistinguishedName = $result.distinguishedname[0]
            samAccountName = $result.samaccountname[0]
            Givenname = $result.givenname[0]
            Surname = $result.sn[0]
            ThumbnailPhoto = $result.thumbnailphoto
        }
    }
}
 
function Update-WPFForm($sam) {
   $selecteduser = Get-LDAPUsers $sam
   if ($selecteduser) {
        $global:userinfo.dn = $selecteduser.DistinguishedName
        $global:userinfo.photo = [byte[]]$($selecteduser.thumbnailphoto)
 
        $lblGivenName.Content = $selecteduser.GivenName
        $lblSN.Content = $selecteduser.SurName
        $lblUPN.Content = $selecteduser.UserPrincipalName
        $lblDN.Content = $global:userinfo.dn
        $txtSearch.Text = $selecteduser.SamAccountName
        $imgThumb.Source = $global:userinfo.photo
        $btnImg.IsEnabled = $true
    }
}
 
#region GUI
    $reader=(New-Object System.Xml.XmlNodeReader $xaml)
    $window=[Windows.Markup.XamlReader]::Load($reader)
 
    $btnSearch = $window.FindName("btnSearch")
    $btnImg = $window.FindName("btnImg")
    $btnApply = $window.FindName("btnApply")
    $txtSearch = $window.FindName("txtSearch")
    $lblGivenName = $window.FindName("lblGivenName")
    $lblSn = $window.FindName("lblSn")
    $lblUPN = $window.FindName("lblUPN")
    $lblDN = $window.FindName("lblDN")
    $imgThumb = $window.FindName("imgThumb")
#endregion GUI
 
#region Events
 
    #region btnSearchClick
        $btnSearch_click = $btnSearch.add_Click
        $btnSearch_click.Invoke({
            Update-WPFForm($txtSearch.Text)
        })
    #endregion btnSearchClick
 
    #region btnImgClick
        $btnImg_click = $btnImg.add_Click
        $btnImg_click.Invoke({
            $file = Invoke-FileBrowser -Title "Choose a thumbnail photo" -Filter "Jpeg pictures (*.jpg)|*.jpg"
            if ($file) {
                if ((Get-Item $file).length -gt 10kB) {
                    Write-Warning "Picture too large. Maximum size is 10 kB."
                } else {
                    $global:userinfo.photo = [byte[]](Get-Content $file -encoding byte)
                    $imgThumb.Source = $global:userinfo.photo
                    $btnApply.IsEnabled = $true
                }
            }
        })
    #endregion btnImgClick
     
    #region btnApplyClick
        $btnApply_click = $btnApply.add_Click
        $btnApply_click.Invoke({
            $user = New-Object DirectoryServices.DirectoryEntry "LDAP://$($global:userinfo.dn)"
            $user.put("thumbnailPhoto", $global:userinfo.photo)
            $user.setinfo()
            $btnApply.IsEnabled = $false
            Write-Verbose "User $($global:userinfo.dn) updated."
        })
    #endregion btnApplyClick
     
    #region textChange
        $txtSearch_Change = $txtSearch.add_KeyDown
        $txtSearch_Change.Invoke({
            if ($_.Key -ieq 'Return') {
                Update-WPFForm($txtSearch.Text)
            }
        })
    #endregion textChange
 
#endregion Events
 
$window.ShowDialog() | Out-Null

This is just an example how a useful script with GUI can be written with quite simple means. Now let’s get the camera and take some photos!

/ Andreas

Office 365: Migrate from Cloud identities to Dirsync

In some scenarios, you may have to transfer the the source of authority for your already cloud-only created user accounts in Office 365. One scenario when this is required, is when changing from cloud only identities to synchronized identities from your local Active Directory. Another scenario is when you migrate your AD users from one domain to another, as described in this post.

To make the transitioning to DirSync as smooth as possible, you have to take a couple of precautions to make sure that existing Office 365 accounts are properly matched with the accounts in your local Active Directory.

This can be done using one of the following two methods:

  • Soft matching of each user. For a soft match to properly work, you need to make sure that the primary email address (both in mail and in proxyaddresses) and userprincipalname in the cloud and in the local Active Directory matches.
  • For the users that are to be matched, construct an ImmutableID for each user in the local AD and synchronize that value to WAAD prior activation and doing the first Directory Synchronization. The ImmutableID is basically a Base64-encoded value of the ObjectGuid attribute.

Solution
To make this transition easier, I have created a script that and copies the required attributes from WAAD to your local Active Directory automatically. The script can use both methods to match the users.

If you run the script without any parameters, the following attributes will be updated for all matching users in your local Active Directory:

  • GivenName
  • sn (SurName)
  • DisplayName
  • mail
  • proxyAddresses

If using the -UpdateImmutableID switch, an ImmutableID will be created for the user and synced back to WAAD. Please note, that process will only work without additional steps if you have not been using DirSync before.

With the -CreateNewUsers switch, you have the possibility to let the script create users that are in WAAD but not in your local AD. The SamAccountname will be “firstname.lastname” and a random password will be generated for the new user.

2014-04-18 20-53-32
2014-04-18 20-56-44
2014-04-18 20-57-41

Note: a pre-requisite for the script to work properly is that you’ve already populated the UserPrincipalName attribute for all users in your local Active directory. Of course this must match with the UPN’s used in Office 365/WAAD.

SyncFrom365toAD.ps1

<#  
.SYNOPSIS 
    Script that synchronizes attributes from users in WAAD to the local active directory.
.PARAMETER UpdateImmutableID 
    Constructs and inserts the ImmutableID attribute in WAAD.
.PARAMETER TargetOU
    OU to create new users in.
.PARAMETER CreateNewUsers
    Creates new users for users that are not matching the WAAD users UPN.
.NOTES 
    Author: Johan Dahlbom 
    Blog: 365lab.net 
    Email: johan[at]dahlbom.eu 
    The script are provided “AS IS” with no guarantees, no warranties, and they confer no rights.     
#>
param(
[parameter(Mandatory=$false)]
[switch]$UpdateImmutableID = $false,
[parameter(Mandatory=$false)]
[switch]$CreateNewUsers = $false,
[parameter(Mandatory=$false)]
[string]$TargetOU = "OU=Users,OU=365Lab,DC=cloud,DC=lab"
)
 
#Load required Modules and Assemblys
Import-Module MSOnline
Import-Module ActiveDirectory
[Reflection.Assembly]::LoadWithPartialName("System.Web") | Out-Null
 
Connect-MsolService
 
$WAADUsers = Get-MsolUser -All | Where-Object {$_.UserPrincipalName -notlike "*.onmicrosoft.com"}
 
foreach ($WAADUser in $WAADUsers) {
    $ADuser = Get-ADUser -Filter {UserPrincipalName -eq $WAADUser.UserPrincipalName}
      
    try {
 
        $Mail = $($WAADUser.ProxyAddresses | Where-Object {$_ -cmatch "^SMTP"}).substring(5)
         
        if ($ADuser) {
        #Update AD user with attributes from WAAD
        Set-ADUser -Identity $ADuser `
            -EmailAddress $Mail `
            -GivenName $WAADUser.FirstName `
            -SurName $WAADUser.LastName `
            -DisplayName $WAADUser.DisplayName `
            -Replace @{ProxyAddresses=[string[]]$WAADUser.ProxyAddresses} `
            -ErrorAction Stop -WarningAction Stop
 
        Write-Output "SUCCESS: Updated $($ADuser.userprincipalname) with proper attributes from WAAD"
         
        } else {
 
            if ($CreateNewUsers) {
 
                     #Generate password for the user aduser
                     $NewUserPassword = [System.Web.Security.Membership]::GeneratePassword(13,0)
 
                     #Generate samaccountname "firstname.lastname"
                     $SAM = [Text.Encoding]::ASCII.GetString([Text.Encoding]::GetEncoding("Cyrillic").GetBytes("$($WAADUser.Firstname).$($WAADUser.Lastname)".ToLower()))
                                           
                     New-ADUser -Path $TargetOU `
                        -Name $WAADUser.DisplayName `
                        -UserPrincipalName $WAADUser.UserPrincipalName `
                        -SamAccountName $sam `
                        -Enabled $true `
                        -EmailAddress $Mail `
                        -GivenName $WAADUser.FirstName `
                        -SurName $WAADUser.LastName `
                        -DisplayName $WAADUser.DisplayName `
                        -OtherAttributes @{ProxyAddresses=[string[]]$WAADUser.ProxyAddresses} `
                        -AccountPassword (ConvertTo-SecureString $NewUserPassword -AsPlainText -force) `
                        -ErrorAction Stop -WarningAction Stop
 
                     Write-Output "SUCCESS: Created user $sam with password $NewUserPassword"

            } else {
                    
                     Write-Warning "User $($WAADUser.UserPrincipalName) is not in AD, user might not be synchronized correctly or become a duplicate user.`r`nCreate the user manually or run the script with -CreateNewUsers switch."
            
            }
        }
               
        #If UpdateImmutableID switch has been used, construct and set ImmutableID for the WAAD user.
        if ($UpdateImmutableID) {
            $ADuser = Get-ADUser -Filter {UserPrincipalName -eq $WAADUser.UserPrincipalName}
            if ($ADuser) {
                $ImmutableID = [system.convert]::ToBase64String($ADuser.ObjectGUID.ToByteArray())
                Set-MsolUser -UserPrincipalName $WAADUser.UserPrincipalName -ImmutableId $ImmutableID -ErrorAction Stop -WarningAction Stop
            }
        }
      
   } catch [Microsoft.Online.Administration.Automation.MicrosoftOnlineException] {
             
            Write-Warning "Error when updating ImmutableID $ImmutableID for $Mail`r`n$_"
 
   } catch {
            
            Write-Warning "Error when creating user $Mail`r`n$_"
        
   }
 
}

Good luck running the script and with your Dirsync implementation!

/Johan

Office 365: Assign licenses based on groups using PowerShell

Important note: The end of an era with licensing scripts is near… and the beginning of a new one with Azure AD Group Based Licensing is here. Group Based Licensing is now in preview and currently requires a paid Azure AD Subscription. Try it out and give Microsoft your feedback on how they can make it even better! 

Update 2016-01-07: Updated the script with a new function to support nested groups.

As a follow up to my earlier post about assigning Office 365 licenses based on ad attribute, I’ve now created another script that uses another approach to assign the licenses.
Instead of using ad attributes, we are here using security groups to assign the licenses.

Since you want to automate license assignment, I am assuming that you are using DirSync.
In this script, the groups and group members are enumerated directly in Windows Azure Active Directory. This means that the groups you will use to assign licenses, must be synchronized to WAAD. If you are looking for a more advanced script with change and removal functions, check this one out.

Note: Users that already have a license are not processed by the script, nor are licenses removed from users that are not longer member of the groups.

Getting started
Getting started with the script is very easy. In the $Licenses hash table, you define the licenses to assign, the group to use for assignment and the license SKU.
The license SKU id’s for your tenant are found with the Get-MsolLicenseSKU cmdlet, as below:

2014-04-15 22-10-25

Here you will find a good translation table that will help you interpret the License SKU ID to the actual license type. When you have done that, just to put in your O365 credentials and UsageLocation in the script and then you’re all set to schedule the script.

2014-04-15 22-37-53

LicenseO365Users.ps1

#2016-01-07 - Updated the script with a new function to support nested groups.
#Import Required PowerShell Modules
#Note - the Script Requires PowerShell 3.0!
Import-Module MSOnline

#Office 365 Admin Credentials
$CloudUsername = 'admin@tenant.onmicrosoft.com'
$CloudPassword = ConvertTo-SecureString 'password' -AsPlainText -Force
$CloudCred = New-Object System.Management.Automation.PSCredential $CloudUsername, $CloudPassword

#Connect to Office 365
Connect-MsolService -Credential $CloudCred
function Get-JDMsolGroupMember {
    param(
        [CmdletBinding(SupportsShouldProcess=$true)]
        [Parameter(Mandatory=$true, ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=0)]
        [ValidateScript({Get-MsolGroup -ObjectId $_})]
        $ObjectId,
        [switch]$Recursive
    )
    begin {
        $MSOLAccountSku = Get-MsolAccountSku -ErrorAction Ignore -WarningAction Ignore
        if (-not($MSOLAccountSku)) {
            throw "Not connected to Azure AD, run Connect-MsolService"
        }
    }
    process {
        $UserMembers = Get-MsolGroupMember -GroupObjectId $ObjectId -MemberObjectTypes User -All
        if ($PSBoundParameters['Recursive']) {
            $GroupsMembers = Get-MsolGroupMember -GroupObjectId $ObjectId -MemberObjectTypes Group -All
            $GroupsMembers | ForEach-Object -Process {
                $UserMembers += Get-JDMsolGroupMember -Recursive -ObjectId $_.ObjectId
            }
        }
        Write-Output ($UserMembers | Sort-Object -Property EmailAddress -Unique) 

    }
    end {

    }
}

$Licenses = @{
                 'E1' = @{
                          LicenseSKU = 'mstlabs:STANDARDPACK'
                          Group = 'E1_Users'
                        }                           

                 'E3' = @{
                          LicenseSKU = 'mstlabs:ENTERPRISEPACK'
                          Group = 'Office 365 E5'
                        }
            }
$UsageLocation = 'SE'

foreach ($license in $Licenses.Keys) {

    $GroupName = $Licenses[$license].Group
    $GroupID = (Get-MsolGroup -All | Where-Object {$_.DisplayName -eq $GroupName}).ObjectId
    $AccountSKU = Get-MsolAccountSku | Where-Object {$_.AccountSKUID -eq $Licenses[$license].LicenseSKU}

    Write-Output "Checking for unlicensed $license users in group $GroupName with ObjectGuid $GroupID..."

    $GroupMembers = (Get-JDMsolGroupMember -ObjectId $GroupID -Recursive | Where-Object {$_.IsLicensed -eq $false}).EmailAddress

    if ($AccountSKU.ActiveUnits - $AccountSKU.consumedunits -lt $GroupMembers.Count) {
        Write-Warning 'Not enough licenses for all users, please remove user licenses or buy more licenses'
      }

        foreach ($User in $GroupMembers) {
          Try {
            Set-MsolUser -UserPrincipalName $User -UsageLocation $UsageLocation -ErrorAction Stop -WarningAction Stop
            Set-MsolUserLicense -UserPrincipalName $User -AddLicenses $AccountSKU.AccountSkuId -ErrorAction Stop -WarningAction Stop
            Write-Output "Successfully licensed $User with $license"
          } catch {
            Write-Warning "Error when licensing $User`r`n$_"
          }

        }

}

Looking for other script options using groups?
Office 365: Assign licenses based on groups using PowerShell – Advanced version – adds and removes licenses for the users as well.
Office 365: Assign individual parts of licenses based on groups using PowerShell – script with the ability to add and remove serviceparts of a license.

There are lots of things that are not in the script that could also be automated, please help us prioritize with the features you’d like to have the most.

/Johan

Logging on as Domain Admin to end user workstations? Think again!

We’ve all been there.
An end user has a problem with their computer or needs manual installation of some software. Either we elevate with an admin account logged on as the end user, or we switch user and are logging on with our admin account.
Far too often, the admin accounts used to help out end users, are very privileged accounts, 2/5 times I see this at my customers those accounts are Domain Admins.

Why is this a problem? Well, first of all, you should never expose very privileged credentials to “non trusted” computers. Secondly, at the time you log on, your credentials are exposed and can with Benjamin “gentilkiwi” Delpy’s tool mimikatz be extracted in clear text through the lsass process.

Ok, but the user is not local admin, so they will not be able to do it anyway? That is true, but far too many let end users be local admins, and of course there are ways to get around that if you’re a non admin user as well.

Example:
The end user Aaron that has been privileged enough to get local admin rights on his computer. He puts the powershell-script Invoke-Mimikatz.ps1 as a task to run at logon for all users.
2014-03-12 13-33-19

2014-03-12 13-44-04
In this example, I’ve just put in the row below in at the end of the script to dump the credentials to a file on the c-disk. This could of course be dumped anywhere if you wanted to.

Invoke-Mimikatz -dumpcreds | 
    Out-File -Append  c:\evilplace\$env:computername.txt

Effect:
Now, when his fellow administrator logs on to his computer to help him with some software installation, the admin credentials will be dumped to a file and Aaron is from now on Domain Admin!

2014-03-12 13-54-02

Scary? Yes!
Lesson learned: Think both once and twice before logging on with privileged credentials to non trusted computers.

This is of course nothing new, it’s been out for quite a while. Look in to the following matrix that clarifies when this is an issue or not (in most cases it is…:( )

/Johan

Office 365: Monitor and finish remote mailbox moves

A while ago, I posted a script that helps you out informing end users and starting remote mailbox moves in hybrid migration scenarios.
In the script we started our mailbox moves with the -SuspendWhenReadyToComplete switch. That switch means we manually have to go in and resume the mailbox moves with the Resume-MoveRequest cmdlet.

In this follow up script, we automate that process by monitoring active mailbox moves and handle resuming and removing of them. We also have the possibility to send out emails to our end users when the move has been completed (if uncommenting row 78).

Note: The script only works as intended if you have created one moverequest per user, as done in my script that start the migrations.

<#  
.SYNOPSIS 
    Script that monitors remote mailbox moves in Exchange Online and handles resuming, removing of them. 
.NOTES 
    Author: Johan Dahlbom 
    Blog: 365lab.net 
    Email: johan[at]dahlbom.eu 
    The script are provided “AS IS” with no guarantees, no warranties, and they confer no rights.     
#>

#region variables
$CloudUsername = "migration@365lab.net"
$CloudPassword = ConvertTo-SecureString "password" -AsPlainText -Force
$CloudCred = New-Object System.Management.Automation.PSCredential $CloudUsername, $CloudPassword
$PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$Logfile = ($PSScriptRoot + "\HybridMigrations.log")
$enduserinfo = @"
Hi $($MoveRequest.batchname),<p>

You've just been moved to Office 365, if you have questions regarding the move, or if anything is not working as intended, let us know!
<p>
<strong>Best Regards
IT Department
</strong>
"@
#endregion variables

#region functions
#Function to send out email with defined parameters
function Send-MigrationMail {
param(
    [Parameter(Mandatory=$true)]
    [string]$Message,
    [Parameter(Mandatory=$true)]
    [string]$Recipient
)
$emailFrom = "Office 365 <migration@365lab.net>"
$smtpserver = "mailserver.365lab.net"
$subject = "Migration batch Finished"
$cc = "migrationadmin@365lab.net"
 
Send-MailMessage -From $emailfrom -To $Recipient -Cc $cc -SmtpServer $smtpserver -Subject $subject -Body $Message -BodyAsHtml
 
}
#Function to write information to a log file and to console output
function Write-Log {
Param (
    [Parameter(Mandatory=$true)]
    [string]$Logstring
)
Add-Content $Logfile -value $logstring -ErrorAction Stop
Write-Output $logstring
}
#endregion functions

#Connect to Exchange Online
if (!(Get-Command Get-CloudMoveRequest -ErrorAction SilentlyContinue)) {
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -Authentication Basic -ConnectionUri https://ps.outlook.com/powershell -AllowRedirection:$true -Credential $CloudCred
    Import-PSSession $session -Prefix Cloud -CommandName @("Get-MoveRequest","Resume-MoveRequest","Remove-MoveRequest","Get-MoveRequestStatistics") -AllowClobber
}

#Run the following as long as there are active move requests
while (Get-CloudMoveRequest)
{
	$MoveRequests = Get-CloudMoveRequest
	foreach ($MoveRequest in $MoveRequests) {
		switch ($MoveRequest.status) { 
			"InProgress" 	{ 
				Write-Output "INFO: Move request for $($MoveRequest.Batchname) at $((Get-CloudMoveRequestStatistics -Identity $MoveRequest.BatchName).PercentComplete)% $(Get-date -format u)" 
			}
			"AutoSuspended" {
				Write-Log "INFO: Completing mailbox move for $($MoveRequest.BatchName) at $(get-date -Format u)"
				Resume-CloudMoveRequest -identity $moverequest.batchname
			}
			"Completed" 	{
				Write-Log "INFO: Removing completed MoveRequest $($MoveRequest.Batchname) at $(get-date -Format u)"
                #Uncomment row below to send information to end users when migration has been completed.
                #Send-MigrationMail -Message $enduserinfo -Recipient $moverequest.batchname 
				Remove-CloudMoveRequest -identity $moverequest.batchname -confirm:$false 
			}
		}
	}
	
	Write-Host "------------------------------------------------------------------------------"
	Start-Sleep 60
}

 Write-Log "INFO: All migration batches finished at $(Get-Date -format u)"
 #Send information to admin then the batches are complete.
 Send-MigrationMail -Message "Migration batch finished at $(Get-Date -format u)<br>Logfile attached." -Recipient migrator@365lab.net
 

Hope this is at any help, just let me know if you have suggestions that can improve the script.

/Johan

Office 365: Enable Multi Factor Authentication with PowerShell

Now when Multi Factor Authentication is free in Office 365 for all users, you might want to automate the activation of the service. And yes, you guessed it right, the way to do that is with PowerShell! 🙂 If you are running Office 365 in a Small Business or Small Business premium plan, this is currently the only way to enable MFA.

In this case we use the Windows Azure Active Directory Module for Windows PowerShell, which can be downloaded from here.

Enable Multi-Factor Authentication for users with PowerShell
In order to enable MFA for a user with PowerShell, we need to use the the object Microsoft.Online.Administration.StrongAuthenticationRequirement and put that with some additional settings in to the StrongAuthenticationRequirements attribute.

Note: After enabling MFA, the user will have to login through the portal and enroll their MFA methods and eventual app passwords before they will be able to logon to the services again.

#Create the StrongAuthenticationRequirement object and insert required settings
$mf= New-Object -TypeName Microsoft.Online.Administration.StrongAuthenticationRequirement
$mf.RelyingParty = "*"
$mfa = @($mf)
#Enable MFA for a user
Set-MsolUser -UserPrincipalName aaron.beverly@365lab.net -StrongAuthenticationRequirements $mfa

#Enable MFA for all users (use with CAUTION!)
Get-MsolUser -All | Set-MsolUser -StrongAuthenticationRequirements $mfa

#Disable MFA for a user
$mfa = @()
Set-MsolUser -UserPrincipalName aaron.beverly@365lab.net -StrongAuthenticationRequirements $mfa

MFA_2

Find your Multi Factor Authentication enabled users
If we want to know what users that have MFA enabled, the attribute StrongAuthenticationRequirements tells us that a user has MFA enabled, and the attribute StrongAuthenticationMethods tells us that a user has enrolled their MFA methods (Phone, App, Text etc.).

#Find all MFA enabled users
Get-MsolUser | Where-Object {$_.StrongAuthenticationRequirements -like "*"}  | select UserPrincipalName,StrongAuthenticationMethods,StrongAuthenticationRequirements

#Find all MFA enabled users that have enrolled their MFA methods
Get-MsolUser | Where-Object {$_.StrongAuthenticationMethods -like "*"}  | select UserPrincipalName,StrongAuthenticationMethods,StrongAuthenticationRequirements

As seen in the screenshot below, only one of my MFA enabled users have actually enrolled their MFA methods.
MFA-Enabled-Enrolled

Not to hard, right? Consider adding this as a step for certain users (eg. admins or other user groups) in your automated process of enabling users in Office 365.

/Johan

PowerShell GPO Reporting: WSUS

In addition to my earlier creations that gives you an inventory of your GPO Deployed Printers and GPP Drive Maps, I’ve now created a similiar script that makes inventory of WSUS settings in all your GPO’s.
This can very much come in handy when having an extensive amount of GPO’s that are controlling WSUS settings (e.g. for different maintenance schedules).

See the example below of the output:
2014-02-05 07-17-52

<#
.SYNOPSIS     
Function that find certain information about all your WSUS related GPO's.
.NOTES     
           File Name: Get-GPOWsusInfo    
           Author   : Johan Dahlbom, johan[at]dahlbom.eu     
           The script are provided “AS IS” with no guarantees, no warranties, and it confer no rights. 
           Blog     : 365lab.net
#>
function Get-GPOWsusInfo {

try
{
Import-Module GroupPolicy -ErrorAction Stop
}
catch
{
throw "Module GroupPolicy not Installed"
}
        $GPO = Get-GPO -All

        foreach ($Policy in $GPO){

                $GPOID = $Policy.Id
                $GPODom = $Policy.DomainName
                $GPODisp = $Policy.DisplayName

                [xml]$xml = Get-GPOReport -Id $GPOID -ReportType xml
                $GPOSec = Get-GPPermissions -All -Guid $GPOID
                $WSUSBase = Get-GPRegistryValue -Guid $GPOID -Key HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\WindowsUpdate -ErrorAction SilentlyContinue
                $WSUSAU = Get-GPRegistryValue -Guid $GPOID -Key HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\WindowsUpdate\AU   -ErrorAction SilentlyContinue

                    if ($WsusBase) {

                        New-Object PSObject -Property @{
                           	GPOName = $GPODisp
                            GPOLinks = $xml.gpo.Linksto.SOMPath
                            GPOFilter = ($GPOSec | Where-Object {$_.Permission -eq "GpoApply"}).trustee.name
                            ScheduleDay = ($WSUSAU | Where-Object {$_.ValueName -eq "ScheduledInstallDay"}).Value.tostring().Replace("0","0 - Every Day").Replace("1","1 - Sunday").Replace("2","2 - Monday").Replace("3","3 - Tuesday").Replace("4","4 - Wednesday").Replace("5","5 - Thursday").Replace("6","6 - Friday").Replace("7","7 - Saturday")
                            Installtime =  ($WSUSAU | Where-Object {$_.ValueName -eq "ScheduledInstallTime"}).Value.tostring() + ':00'
                            AutoUpdateSetting = ($WSUSAU | Where-Object {$_.ValueName -eq "AUOptions"}).Value.tostring().Replace("2","2 - Notify for download and notify for install").Replace("3","3 - Auto download and notify for install").Replace("4","4 - Auto download and schedule the install").Replace("5","5 - Allow local admin to choose setting")
                            WSUSTargetGroup = ($WSUSBase | Where-Object {$_.ValueName -eq "TargetGroup"}).Value
                            WSUSServer = ($WSUSBase | Where-Object {$_.ValueName -eq "WUServer"}).Value
                        }
                    }

           }
}

Hope you find this useful!
When I find the time I’ll create a more complete set of GPO reporting functions with more functionality than they have today, maybe with help from Ramblingcookiemonster that has extended and created additions to the GP Preferences functions.

Until next time, Happy GPO Reporting!

/Johan

Office 365: Start hybrid migrations and inform users

Lately I’ve been doing quite a few Hybrid setups.
One of the things that cause quite a lot of pain when doing migrations in general is getting information out to your users.
Even if you have a good communication plan, only 20% of your users read the information and only 5% of them reflect and understand it. 🙂

To make this a bit easier, I’ve created a small script that starts Hybrid migration batches and sends out information to the end user.
This is of course just an example, but gives you a good idea of what you are able to do with quite simple means.

HybridMigrationEmail

Getting started:

  • Review all relevant variables in the script
  • Change the text in BodyHybrid.txt to fit in to your needs, the html in that file will be the information sent in the email. The words ActiveSyncDevices,OWAAddress,EmailAddress and Firstname will automatically be replaced by its corresponding variables. (and no the css is not the best 🙂 )
  • Create the PDF Office365Info.pdf and put it in the same folder as the script, that file will be attached to the email.
  • Create input file Prepare-Mailboxes.csv with samAccountNames of the users that you want to migrate. (Note that I am assuming that the users Email Address and UserPrincipalName are the same in the script)
    Also note that the MoveRequest settings in the script might not fit your needs (Baditemlimit, Largeitemlimit).

As we are using the switch -SuspendWhenReadytoComplete in the script, the batches will autosuspend at 95% completion.
That means you’ll have to complete them manually with for example the following line:

Get-CloudMoveRequest | Where-Object {$_.Status -eq "AutoSuspended"} | Resume-CloudMoveRequest. 

Another good option to get in better control of your remote mailbox moves is to use Michael Halls excellent tool based on PowerShell and Excel that does that for you.

Start-HybridMigrations.ps1

<#
.SYNOPSIS
    Starts hybrid migrations based on input (samAccountName) from a csv file.
.DESCRIPTION
    The script starts hybrid migrations and sends out information emails based on the file "BodyHybrid.txt".
    PowerShell V3.0 required
.NOTES
    File Name: Start-HybridMigrations.ps1
    Author   : Johan Dahlbom, johan[at]dahlbom.eu
    The script are provided “AS IS” with no guarantees, no warranties, and they confer no rights.
#>

#Import Required Modules
Import-Module ActiveDirectory

#Define Mail Information and Logging
$Users = Get-Content Prepare-MailboxMoves.csv
$BodyHybrid = Get-Content BodyHybrid.txt -Raw
$OWAAddress = 'http://outlook.com/365lab.net'
$Attachment = Get-Childitem .\Office365Info.pdf
$PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$Logfile = ($PSScriptRoot + "\HybridMigrations.log")

#Define Exchange Online Variables
$ExchServ = "exchangecas.corp.365lab.net"
$HybridServer = "webmail.365lab.net"
$DeliveryDomain = "365lab.mail.onmicrosoft.com"
$OnPremiseUsername = "cloud\svc_mailmigration"
$OnPremisePassword = ConvertTo-SecureString "password" -AsPlainText -Force
$CloudUsername = "svc-cloudmigration@365lab.onmicrosoft.com"
$CloudPassword = ConvertTo-SecureString "password" -AsPlainText -Force
$CloudCred = New-Object System.Management.Automation.PSCredential $CloudUsername, $CloudPassword
$OnPremCred = New-Object System.Management.Automation.PSCredential $OnPremiseUsername, $OnPremisePassword

#Connect to OnPremise Exchange
    if (!(Get-Command Get-ActiveSyncDeviceStatistics -ErrorAction SilentlyContinue)) {
    try {
        Add-PSSnapin Microsoft.Exchange.Management.PowerShell.E2010
    } catch {
            $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$ExchServ/powershell" -Authentication Kerberos
            Import-PSSession -Session $Session -CommandName  Get-ActiveSyncDeviceStatistics -FormatTypeName * | Out-Null
        }
    }
#Connect to Exchange Online
    if (!(Get-Command New-CloudMoveRequest -ErrorAction SilentlyContinue))
    {
	    $session = New-PSSession -ConfigurationName Microsoft.Exchange -Authentication Basic -ConnectionUri https://ps.outlook.com/powershell -AllowRedirection:$true -Credential $CloudCred
	    Import-PSSession $session -Prefix Cloud
    }

function LogWrite {
Param ([string]$Logstring)
Add-Content $Logfile -value $logstring -ErrorAction Stop
Write-Host $logstring
}

function Send-MigrationMail {
param(
[Parameter(Mandatory=$true)]
[string]$Message,
[Parameter(Mandatory=$true)]
[string]$Recipient
)
    $emailFrom = "Office 365 Migration <office365@365lab.net>"
    $smtpserver = "smtpyrelay.365lab.net"
    $subject = "Email Migration Started"
    $cc = "migrator@365lab.net"

    Send-MailMessage -From $emailfrom -To $Recipient -Cc $cc -SmtpServer $smtpserver -Subject $subject -Body $Message -BodyAsHtml -Attachments $Attachment

}
LogWrite "$(get-date -Format u)"
foreach($aduser in $users) {
	$emailAddress = (Get-ADuser $aduser -properties UserPrincipalName).UserPrincipalName
	$firstname = (Get-ADuser $aduser).givenname
        #Find the number of Active ActiveSyncDevices that the user have in the local Exchange solution. If not, set to 0.
		$ActiveSyncDevices = (Get-ActiveSyncDeviceStatistics -Mailbox $aduser | Where-Object {$_.LastSuccessSync -gt (Get-Date).AddDays(-30)}).count
        if (!($ActiveSyncDevices)) {
            $ActiveSyncDevices = "0"
        }
	    #Start Email Migration for user
        try {
            New-CloudMoveRequest -Identity $emailaddress -Remote -RemoteHostName $HybridServer -RemoteCredential $OnPremCred -TargetDeliveryDomain $DeliveryDomain -BatchName $emailAddress -SuspendWhenReadyToComplete -BadItemLimit 50 -LargeItemLimit 50
	} catch {
            $err = $_
            Logwrite "ERROR: Failed to start migration on $($emailAddress)`r`n$($err)"
        }

        #Verify that the move request was successful and send information email
        if ((Get-CloudMoveRequest | Where-Object {$_.BatchName -eq $emailAddress} -ErrorAction SilentlyContinue)) {
            LogWrite "INFO: Started migration of user $($emailAddress) $(get-date -format u)"
            Send-MigrationMail -Recipient $emailAddress -Message $BodyHybrid.Replace('firstname',"$firstname").Replace('ActiveSyncDevices',"$ActiveSyncDevices").Replace("emailaddress","$emailaddress").Replace("OWAAddress","$OWAAddress")
        }else{
            LogWrite "ERROR: Could not start migration for user $($emailAddress), please verify that user exists"
        }
}

The script would of course be possible to extend with lots of things like license assignment, watching the mailboxes as they move and lots and lots of other things, but that’s for another day.
Just let me know if you have suggestions on improvements or other things that should be changed.
Download the script complete with the HTML-template and example csv file from here.

Happy migrations!

/Johan