Monday, December 30, 2019

Removing AD-Mastered Users From an Okta Group

When associates leave our company, their Active Directory accounts are automatically disabled, which in turn causes their Okta account to be deactivated. These former associates still need access to Workday, to get their paystubs and to retrieve their W-2 tax documents the following year. To facilitate access to Workday, their Okta accounts are reactivated (which turns them into Okta-mastered accounts) and added to a special Okta group that assigns them to the Workday integration.

Being a retailer, we hire a lot of temporary associates, particularly around the holidays, and quite a few of those are rehires from the previous season. And when a former associate is rehired, although a new AD account is created (because the old one has been deleted by this time), the new account usually has the same username and Workday number that they had previously. This is no big deal, but I recently discovered that most of these new AD accounts, once imported into Okta, were being automatically relinked to their old Okta accounts, the ones that were supposedly now Okta-mastered. And that's also not a big deal, since it requires no admin intervention due to name conflicts. The one negative is that these relinked accounts remain members of that special Okta group that assigns them to Workday. This doesn't cause a problem for the user, but since they also have an AD group membership that assigns them to Workday, membership in the Okta group is redundant. And it just annoys me, so I decided to write a script to remove them from the Okta group, since that's supposed to be for former associates only.

The following powershell script retrieves the entire list of users from the Okta group, then filters that list down to only the user profiles that have Active Directory as their credential provider. Those users are then deleted from the Okta group.

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$api_token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
$groupid = "xxxxxxxxxxxxxxxxxxxx"
$uri = "https://YOURORG.okta.com/api/v1/groups/$groupid/users?limit=1000"
Do {
    $webrequest = Invoke-WebRequest -Headers @{"Authorization" = "SSWS $api_token"} -Method GET -Uri $uri
    $link = $webrequest.Headers.Link.Split("<").Split(">")
    $uri = $link[3]
    $psobjects = $webrequest | ConvertFrom-Json
    $alum += $psobjects
} while ($webrequest.Headers.Link.EndsWith('rel="next"'))
$alumAD = @($alum | where-object {$_.credentials.provider.type -like "ACTIVE_DIRECTORY"})
if ($alumAD.count -gt 0) {
    foreach ($user in $alumAD) {
        $uri = "https://YOURORG.okta.com/api/v1/groups/$groupid/users/$($user.id)"
        $deleterequest = Invoke-WebRequest -Headers @{"Authorization" = "SSWS $api_token"} -Method DELETE -Uri $uri
    }
}

Sunday, December 29, 2019

Automating the Creation of Linked Mailboxes

Our regional IT teams have been delegated rights to create new user accounts in their own OUs. However, access to the on-premises Exchange org is limited to one or two org admins, as well as recipient admin rights for our Service Desk team.

Since there can sometimes be a significant delay between the creation of a new user account and the creation of that user's mailbox (mostly due to different timezones), I developed the following script to both reduce the load on the Service Desk and accelerate the creation of new mailboxes, which ultimately gives the regional IT teams more control over the provisioning process for their users.

The script is fairly specific to our environment, but hopefully the concepts will help others to create similar automation for their own systems. It currently runs as an hourly scheduled task. The regional IT teams simply need to add the string, "MailboxPlease" to the info attribute (on the Telephones tab) of the new user's account for the script to pick it up and create the linked mailbox.

<#
.SYNOPSIS
    NewLinkedMailbox - Creates a linked mailbox for accounts with MailboxPlease in the info attribute
    Created by Mike Koch on October 29, 2019
.DESCRIPTION
    Queries USERDOMAIN for user accounts that have 'MailboxPlease' in the 'info' attribute
        - this attribute is populated by the regional IT teams, which have been delegated rights to create user accounts in their own OUs
    Creates a matching account in the RESOURCEDOMAIN, in the appropriate region OU
    Sets the Company attribute on the mailbox account, to ensure that the proper email address policy is applied
    Creates the linked mailbox, then copies the primary smtp address back to the user's account in USERDOMAIN
    Clears 'MailboxPlease' from the info attribute
.NOTES
    Assumes the account running this script has sufficient rights to do the following:
        1. Read user properties in USERDOMAIN
        2. Create user accounts in RESOURCEDOMAIN
        3. Create linked mailboxes in the on-premises Exchange org
        4. Write user properties in USERDOMAIN (to write the primary smtp address back to the user's account)
#>
[CmdletBinding()]
Param()

$USERDOMAINDC = "dc1.USERDOMAIN.local"
$RESOURCEDOMAINDC = "dc1.RESOURCEDOMAIN.local"

###
# Query USERDOMAIN for enabled accounts containing "MailboxPlease" in the info attribute
###
$mbxrequests = @(Get-ADUser -Filter { enabled -eq $true -AND info -like "*MailboxPlease*" } -Server $USERDOMAINDC -SearchBase "ou=All Users,dc=USERDOMAIN,dc=local" -Properties givenname, sn, title, description, department, office, company, manager)

if (!$mbxrequests) {
    Write-Verbose "No mailbox requests detected."
}
else {
    foreach ($mbx in $mbxrequests) {
        Write-Verbose "Attempting mailbox creation for $($mbx.Name)..."
        $resAccountParms = @{ }
        $resAccountParms.Add("displayName", $mbx.Name)
        $resAccountParms.Add("userprincipalname", "$($mbx.SamAccountName)@COMPANYNAME.com")
        switch -Wildcard ($mbx.DistinguishedName) {
            "*Australia Associates*" { 
                $path = "ou=Australia,ou=LinkedMailboxAccounts,dc=RSEOURCEDOMAIN,dc=local"
                $resAccountParms.Add("company", "COMPANYNAME Australia") # required to trigger custom email address policy
            }
            "*Canada Associates*" { 
                $path = "ou=Canada,ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                $resAccountParms.Add("company", "COMPANYNAME Canada") # required to trigger custom email address policy
            }
            "*France Associates*" { 
                $path = "ou=France,ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                $resAccountParms.Add("company", "COMPANYNAME France") # required to trigger custom email address policy
            }
            "*Germany Associates*" { 
                $path = "ou=Germany,ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                if ($mbx.company) { $resAccountParms.Add("company", $mbx.company)}
            }
            "*Ireland Associates*" { 
                $path = "ou=Ireland,ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                if ($mbx.company) { $resAccountParms.Add("company", $mbx.company)}
            }
            "*Italy Associates*" {
                $path = "ou=Italy,ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                if ($mbx.company) { $resAccountParms.Add("company", $mbx.company)}
            }
            Default {
                $path = "ou=LinkedMailboxAccounts,dc=RESOURCEDOMAIN,dc=local"
                if ($mbx.company) { $resAccountParms.Add("company", $mbx.company)}
            }
        }
        if ($mbx.givenname) { $resAccountParms.Add("givenName", $mbx.GivenName) }
        if ($mbx.sn) { $resAccountParms.Add("sn", $mbx.sn) }
        if ($mbx.Department) { $resAccountParms.Add("department", $mbx.Department) }
        if ($mbx.description) { $resAccountParms.Add("description", $mbx.description) }
        if ($mbx.Title) { $resAccountParms.Add("title", $mbx.Title) }
        if ($mbx.office) { $resAccountParms.Add("physicalDeliveryOfficeName", $mbx.office) }
        $resAccountParms.Add("extensionAttribute1", "migrate.me")  # triggers migration script

        ### Let's see if we can locate this user's manager's mailbox account in RESOURCEDOMAIN
        if ($mbx.Manager) {
            $mgrsam = (Get-ADUser $mbx.Manager -Server $USERDOMAINDC).SamAccountName
            $resAccountParms.Add("Manager", (Get-ADUser $mgrsam -Server $RESOURCEDOMAINDC).DistinguishedName)
        }

        ### Make sure this samaccountname doesn't exist in RESOURCEDOMAIN
        if (!(Get-ADUser -Filter "samaccountname -eq '$($mbx.SamAccountName)'" -Server $RESOURCEDOMAINDC)) {
            Write-Verbose "Creating RESOURCEDOMAIN account for $($mbx.Name)..."
            New-ADUser -Name $mbx.Name -SamAccountName $mbx.SamAccountName -Enabled $FALSE -Path $path -Server $RESOURCEDOMAINDC -OtherAttributes $resAccountParms
            $resacct = Get-ADUser $mbx.SamAccountName -Server $RESOURCEDOMAINDC
            if ($resacct) {
                Write-Verbose "Creating linked mailbox for $($mbx.Name)..."
                $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri http://OnPremExchangeServer/powershell/ -Authentication Kerberos -AllowRedirection
                Import-PSSession $Session -CommandName Enable-Mailbox
                Enable-Mailbox -Identity $resacct.DistinguishedName -DomainController $RESOURCEDOMAINDC -Alias $resacct.SamAccountName -LinkedMasterAccount $mbx.DistinguishedName -LinkedDomainController $USERDOMAINDC
                Get-PSSession | Remove-PSSession

                ### copy email address to the USERDOMAIN account
                $email = (Get-ADUser $mbx.SamAccountName -Server $RESOURCEDOMAINDC -Properties mail).mail
                Set-ADUser $mbx.SamAccountName -Server $USERDOMAINDC -EmailAddress $email -Clear info
            } else {
                Write-Verbose "Creation of RESOURCEDOMAIN account failed $($mbx.SamAccountName)"
            }
        }
        else {
            Write-Verbose "Samaccountname already exists in RESOURCEDOMAIN ($($mbx.SamAccountName))."
        }
    }
}
Write-Verbose "Finished."

Automate the Migration of Linked & Shared Mailboxes

Our current environment consists of Exchange 2010 on-premises and Exchange Online, in hybrid mode with centralized mail flow enabled, and Azure AD Connect synchronizing everything. We have two forests, each with user accounts (result of a merger and a CIO who didn't want to rock the boat and force everyone in one forest to migrate). Both forests had Exchange 2003 at the time, but when we upgraded to 2007 we consolidated down to one Exchange org. Users in the same forest as Exchange have normal user mailboxes, while users in the other forest require linked mailboxes. And it's been that way for more than 10 years. Office 365 and Exchange Online came along a few years ago, but we just got around to migrating all user mailboxes last year.

Although new mailboxes for users in the forest with Exchange can easily be created in Exchange Online by running the Enable-RemoteMailbox command, the users in the other forest still have to be created on-premises as linked mailboxes, and then migrated to Exchange Online. We've also had some challenges with creating shared mailboxes, so those get created on-premises as user mailboxes, then converted to shared mailboxes and THEN migrated to Exchange Online.

The powershell script you see below is my solution to automating the migration of those linked and shared mailboxes. This script runs every hour as a scheduled task, queries the on-premises Exchange for new linked or shared mailboxes, and if any are found, each is submitted in its own migration batch. The only issue I encountered was one of timing - the migration batches would fail if they got submitted before Azure AD Connect had synchronized the on-premises objects up into Exchange Online. I mistakenly thought that the "-StartAfter" parameter of New-MigrationBatch command would allow me to delay the start of the migration. Turns out it only delays the actual data movement, but Exchange Online was starting the object-matching prep work as soon as the batch was submitted. To solve that problem, I added a bit of logic to delay submission of the migration batch until at least one hour after the whenMailboxCreated timestamp, which gives Azure AD Connect plenty of time to get everything in place, and I've had no failures since.

<#
.SYNOPSIS
    MigrateMailboxes - Migrates linked and shared mailboxes to Exchange Online
    Created by Mike Koch on December 20, 2019
.DESCRIPTION
    Remote powershell to on-premises Exchange
        Query Exchange for linked and/or shared mailboxes to migrate
    Remote powershell to Exchange Online
        Submit migration batch request with CSV file
.NOTES
    DEPENDENCIES
    1. Functions-PSStoredCredentials.ps1 - http://practical365.com/blog/saving-credentials-for-office-365-powershell-scripts-and-scheduled-tasks
        - contains functions to store and retrieve encrypted credentials from the local file system
        - required so that script can run unattended
    
    ASSUMPTIONS
    1. Assumes the account running this script has admin rights in the on-premises Exchange environment, as well as Account Operator
        rights in the linked domain.
    
    TO-DO
    1. Integrate my linked mailbox creation script, to result in one script that handles everything, easier to maintain
    #>

[CmdletBinding()]
Param()

$linkedDC = "dc1.userdomain.local"  # needed only to add linked mailbox users to O365 licensing groups

## IMPORTANT: encrypted credentials can only be retrieved by the same account that was used to encrypt them
## AND must be on the same machine where they were encrypted
. "C:\Scripts\Functions-PSStoredCredentials.ps1"
$cred = Get-StoredCredential -UserName globaladmin@yourcompany.onmicrosoft.com

##### Connect to on-premises Exchange, import only the commands we plan to use
$Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri http://OnPremExchangeServer/powershell/ -Authentication Kerberos -AllowRedirection
Import-PSSession $Session -CommandName Get-Mailbox, Set-Mailbox

##### Build a list of mailboxes to migrate
## Some older shared mailboxes stay on-premises, so we'll set a date variable to limit our query to recently created mailboxes
$SharedMailboxThreshold = (Get-Date).AddDays(-30)
## A separate script creates linked mailboxes and sets extensionAttribute1 to "migrate.me"
$MailboxesToMigrate = @(Get-Mailbox | Where-Object {$_.RecipientTypeDetails -like "LinkedMailbox" -AND $_.CustomAttribute1 -like "migrate.me"})
## Returns recently created shared mailboxes that are not already being migrated (see line 90, below)
$MailboxesToMigrate += @(Get-Mailbox | Where-Object {$_.RecipientTypeDetails -like "SharedMailbox" -AND $_.whenMailboxCreated -gt $SharedMailboxThreshold -AND $_.CustomAttribute1 -notlike "migration in progress"})

if ($MailboxesToMigrate.count -gt 0) {
    ##### Initiate remote powershell connection to Exchange Online, import only the commands needed to submit a migration batch
    $exo = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://ps.outlook.com/powershell/ -Credential $cred -Authentication Basic -AllowRedirection
    Import-PSSession $exo -CommandName Get-MigrationEndPoint, New-MigrationBatch
    $MigrationEndpointOnPrem = Get-MigrationEndpoint -Identity owa.on-prem-endpoint.com
    
    foreach ($mbx in $MailboxesToMigrate) {
        ## don't try to migrate a mailbox until it's at least one hour old
        ## this ensures that Azure AD Connect has had enough time to replicate the object to Azure and Exchange Online
        if ((New-TimeSpan -Start $mbx.whenMailboxCreated -End (Get-Date).AddHours(-1)) -gt 0) {
            $mbx | Select-Object @{Name="EmailAddress";Expression={$_.primarysmtpaddress}} | Export-Csv "c:\temp\mbx.csv" -NoTypeInformation

            ## use the mailbox name as the migration batch name, but make sure it doesn't exceed the 64-char limit
            ## very unlikely, but costs almost nothing to do
            $batchname = "$($mbx.displayName)"
            if ($batchname.Length -gt 60) {
                $batchname = $batchname.Substring(0,60)
            }

            ###### Submit the migration batch
            Write-Verbose "Submitting migration batch request..."
            New-MigrationBatch -Name $batchname `
                -SourceEndpoint $MigrationEndpointOnPrem.Identity `
                -TargetDeliveryDomain yourcompany.mail.onmicrosoft.com `
                -CSVData ([System.IO.File]::ReadAllBytes("c:\temp\mbx.csv")) `
                -NotificationEmails "EmailAdmin@yourcompany.com" `
                -AutoStart `
                -AutoComplete

            switch ($mbx.RecipientTypeDetails) {
                "LinkedMailbox" {
                    # clear the migrate.me string from customattribute1
                    Set-Mailbox $mbx.primarySmtpAddress -CustomAttribute1 $null
                    ## we want to assign an EXO license to the user account in the linked domain (not the mailbox account)
                    ## LinkedMasterAccount contains the owner's account, in domain/username format
                    ## we just need to grab the username portion, which is the samaccountname in the linked domain
                    $sam = $mbx.LinkedMasterAccount.split("\")[1]
                    ##### Assign Office Pro Plus and Exchange Online feature licenses to the mailbox owner
                    ## Assumes use of group-based licensing, which requires an Azure AD Premium subscription
                    Add-ADGroupMember -Identity "O365 Exchange Online (E5)" -Members $sam -Server $linkedDC
                    Add-ADGroupMember -Identity "O365 Office Pro Plus (E5)" -Members $sam -Server $linkedDC
                }
                "SharedMailbox" {
                    ## set extensionAttribute1 to avoid adding this mailbox to future migrations (see line 44, above)
                    Set-Mailbox $mbx.primarySmtpAddress -CustomAttribute1 "migration in progress"
                }
                Default {}
            }
        }
    }
    Get-PSSession | Remove-PSSession
}

Write-Verbose "Finished."

Friday, December 27, 2019

Unable to Recreate a Recently Deleted O365 Group

A colleague recently came to me with a problem. One of our "teams" had somehow managed to delete the dynamic Office 365 group that their Team was based on. He was attempting to recreate the group, but each attempt failed with the following error.


New-AzureADMSGroup : Error occurred while executing NewMSGroup
Code: Request_BadRequest
Message: Another object with the same value for property mailNickname already exists.


To this point, I haven't worked with O365 groups very much (or at all), so it was an interesting problem to dig into.

Since the error mentions the 'mailNickname' attribute and I know that's an attribute of the O365 group, it was pretty obvious that the group was still out there somewhere, interfering with the creation of the new group. So, it seemed obvious that the group was only soft-deleted. I just had to figure out how to find it and delete it permanently.

And before anyone points it out, yes, I know that we could have just restored the old group - and that's probably what we should have done. But part of me was curious to confirm that getting rid of the old group would eliminate the error we were getting when we tries to create the new group.

Initially, I was unable to locate the deleted group in the Azure AD portal. All of the team names start with "FLD", and while searching for that string did reveal some deleted groups, the one I was looking for was not among them. Nor was it among the active groups (had to check). Hmmm.

After a bit of googling, I learned of the Get-AzureADMSDeletedGroup command, part of the AzureAD powershell module. That command, with no arguments, returns a list of all soft-deleted groups, and it was on that list that I found my missing group. The default output of Get-AzureADMSDeletedGroup shows the Id, DisplayName, and Description of the group, and this is when I discovered that my colleague had not been consistent with our O365 team group naming. I happened to find the group I was looking for by looking for the unique number in the name. While the object name (which was not in the output from Get-AzureADMSDeletedGroup) may have started with "FLD", the DisplayName did not. But both the name and the DisplayName had that same unique number, which allowed me to locate the group, which also revealed that he DisplayName started with "District", not the expected "FLD".

At this point, I could have deleted the group by using the Remove-AzureADMSDeletedDirectoryObject command, which takes the group's unique ID string. However, now that I located the group in powershell, I wanted to find it in the Azure AD console, too. Based on what I saw in powershell, I searched the deleted groups using "District", and there it was! Right there in the Azure console, I selected the group and permanently deleted it.

Switching back to powershell, I again tried to pull up the details of the group, but this time that failed because the object had been deleted. So then I entered the command that my colleague had tried to run, to recreate the group, and that also succeeded. Problem solved.

New-AzureADMSGroup `
   -DisplayName "FLD DIST 0806" `
   -Description "District 0806 (dynamic)" `
   -MailEnabled $True `
   -SecurityEnabled $True `
   -MailNickname "FLDDIST0806DYN" `
   -GroupTypes "DynamicMembership", "Unified" `
   -MembershipRule "(User.extensionAttribute13 -eq ""0806"")" `
   -MembershipRuleProcessingState "On" `
   -Visibility "Private"

Bye.