PowerShell

Microsoft 365 cross-tenant migration

Hello! Long time no… 🍻😊🍻

I thought I would share some PowerShell I used during a recent cross-tenant migration. Firstly, the Microsoft documentation is really good and got the journey off to a good start:

https://docs.microsoft.com/en-us/microsoft-365/enterprise/cross-tenant-mailbox-migration?view=o365-worldwide

NOTE: Following is some fairly raw PowerShell. You can’t just press Go! You’ll need to understand and update the bits required and run accordingly! Hopefully everything that requires updating is in bold-italics. If not please punish me via comment! =)

That said… let’s continue 😃:

Firstly, let’s create a group in the source tenant called MigUsers@sourcedomain.com and add all of the mailboxes you will migrate (the migration endpoint is scoped to a group, anyone not in the group will fail to migrate).

Now load PowerShell ISE and paste all of the code bits in, then save it for later. First, we need our commands to be able to switch quickly between tenants:

# Source tenant
Connect-ExchangeOnline -UserPrincipalName migadmin@sourcedomain.com
# Target tenant
Connect-ExchangeOnline -UserPrincipalName migadmin@targetdomain.com
# Disconnect from tenant
Disconnect-ExchangeOnline -Confirm:$false

Now we can connect to the target tenant and create the Org Relationship and Migration Endpoint – you’ll need the ‘sourcedomain‘, app ID and secret to paste in here where the bold italics are (follow the MS article above to set up the App Registration and Enterprise App, easy as bro!):

# connect to target tenant here
# Enable customization if tenant is dehydrated
  $dehy = Get-OrganizationConfig | fl isdehydrated
  if ($dehy -eq $true) {Enable-OrganizationCustomization}

# Create Migration Endpoint in target tenant
$AppId = "paste the app id here"
$Credential = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $AppId, (ConvertTo-SecureString -String "paste the app secret here" -AsPlainText -Force)
New-MigrationEndpoint -RemoteServer outlook.office.com -RemoteTenant "sourcedomain.onmicrosoft.com" -Credentials $Credential -ExchangeRemoteMove:$true -Name "SourceDomainMigEndpoint" -ApplicationId $AppId

# Create Org Relationship in target tenant
$sourceTenantId="paste source tenant id here"
$orgrels=Get-OrganizationRelationship
$existingOrgRel = $orgrels | ?{$_.DomainNames -like $sourceTenantId}
If ($null -ne $existingOrgRel)
{
    Set-OrganizationRelationship $existingOrgRel.Name -Enabled:$true -MailboxMoveEnabled:$true -MailboxMoveCapability Inbound
}
If ($null -eq $existingOrgRel)
{
    New-OrganizationRelationship "SourceDomainOrgRel" -Enabled:$true -MailboxMoveEnabled:$true -MailboxMoveCapability Inbound -DomainNames $sourceTenantId
}

Let’s use our disconnect / connect commands above to disconnect from the target tenant and connect to the source tenant to set up the other side:

# connect to source tenant here

# Configure OrgRel in source tenant
$targetTenantId="insert the target tenant ID here"
$appId="insert the app id here"
$scope="MigUsers@sourcedomain.com"
$orgrels=Get-OrganizationRelationship
$existingOrgRel = $orgrels | ?{$_.DomainNames -like $targetTenantId}
If ($null -ne $existingOrgRel)
{
    Set-OrganizationRelationship $existingOrgRel.Name -Enabled:$true -MailboxMoveEnabled:$true -MailboxMoveCapability RemoteOutbound -OAuthApplicationId $appId -MailboxMovePublishedScopes $scope
}
If ($null -eq $existingOrgRel)
{
    New-OrganizationRelationship "targetdomainOrgRel" -Enabled:$true -MailboxMoveEnabled:$true -MailboxMoveCapability RemoteOutbound -DomainNames $targetTenantId -OAuthApplicationId $appId -MailboxMovePublishedScopes $scope
}

Hopefully you got to this point without issue – if not let me know in the comments section and I’ll try to help! Next, we should be getting a successful test of what we have set up. Run this command since you are still connected to the source tenant:

# Confirm OrgRel in Source tenant
Get-OrganizationRelationship | fl name, DomainNames, MailboxMoveEnabled, MailboxMoveCapability

Now, disconnect and connect to the target tenant again, and run this:


# Confirm OrgRel and MigEndpoint in Target tenant
Get-MigrationEndpoint
Get-OrganizationRelationship | fl name, DomainNames, MailboxMoveEnabled, MailboxMoveCapability
Test-MigrationServerAvailability -Endpoint "SourceDomainMigEndpoint"

Awesome, green lights!? The tenants are configured. Now from the source tenant, we’ll need to get the following details out into a CSV file so we can create the MailUser objects in the target tenant:

# get source mailbox details
Get-Mailbox | select DisplayName, UserPrincipalName, PrimarySMTPAddress, ExchangeGUID, ArchiveGUID, LegacyExchangeDN | Export-Csv C:\temp\sourceusers.csv -NoTypeInformation

We want to test one user first, so I used these commands (replace bold italic with values for one user from the CSV output):

# create MailUsers in target tenant - ONE AT A TIME HERE OR BELOW FOR BATCHES
$originalalias = 'sales'
$newalias = 'sourcedomain.sales'
$userdisplayname = 'sourcedomain Sales Department'
$exchguid = '80ddefd4-26cb-7621-c497-g6c044b04dd9'
$archguid = '70edegc5-36f8-8639-b287-f6g035408ec2'
$x500address = 'x500:/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=ce04185c5ff841f885d660e46e6c8bc5-sourcedomain SAL'
$usersourceaddress = $originalalias + "@sourcedomain.onmicrosoft.com"
$usertargetaddress = $newalias + "@targetdomain.com"
$usertargettenantaddress = $newalias + "@targetdomain.onmicrosoft.com"
New-MailUser -Name $newalias -DisplayName $userdisplayname -ExternalEmailAddress $usersourceaddress -MicrosoftOnlineServicesID $usertargetaddress -Password (ConvertTo-SecureString -String 'Hellosourcedomain2099' -AsPlainText -Force)
Set-MailUser $usertargetaddress -ExchangeGuid $exchguid -ArchiveGuid $archguid -PrimarySmtpAddress $usertargetaddress
Set-MailUser $usertargetaddress -EmailAddresses @{Add="$x500address","$usertargettenantaddress"}
Set-MailUser $usertargetaddress -EmailAddresses @{Remove="smtp:$usersourceaddress"}

Then we can migrate the user:

New-MigrationBatch -Name testbatch -SourceEndpoint 'sourcedomainMigEndpoint' -UserIds $usertargetaddress -Autostart -TargetDeliveryDomain 'targetdomain.onmicrosoft.com'

Once this is working and we are confident all is well, copy then open the CSV output and add columns ‘originalalias’ (the bit before the UPN), ‘newalias’ (set as needed for the target tenant) and ‘newupn’ (new UPN in the target tenant):

Assuming the file is called batch1.csv, create another CSV called batch1-users.csv. This is the input for the migration batch command (very simple and worked well for me), just copy the target UPNs into the file and populate the other columns as below:

Now you can use this code to create the MailUser objects with the first CSV file:

# TO DO BATCHES
# UPDATE THESE CORRECTLY - set variables and import csv
$batchname = 'batch1'
$csv = Import-Csv C:\temp\sourcedomain\batch1.csv

foreach ( $line in $csv ) {

$originalalias = $line.OriginalAlias
$newalias = $line.NewAlias
$userdisplayname = $line.DisplayName
$exchguid = $line.ExchangeGuid
$archguid = $line.ArchiveGuid
$x500address = "X500:" + $line.LegacyExchangeDN
$usersourceaddress = $originalalias + "@sourcedomain.onmicrosoft.com"
$usertargetaddress = $newalias + "@targetdomain.com"
$usertargettenantaddress = $newalias + "@targetdomain.onmicrosoft.com"

Write-Host
Write-Host -ForegroundColor Green "Processing $usertargetaddress..."
Write-Host

New-MailUser -Name $newalias -DisplayName $userdisplayname -PrimarySmtpAddress $usertargetaddress -ExternalEmailAddress $usersourceaddress -MicrosoftOnlineServicesID $usertargetaddress -Password (ConvertTo-SecureString -String 'Hellosourcedomain2099' -AsPlainText -Force)
Set-MailUser $usertargetaddress -ExchangeGuid $exchguid -ArchiveGuid $archguid -PrimarySmtpAddress $usertargetaddress
Set-MailUser $usertargetaddress -EmailAddresses @{Add="$x500address","$usertargettenantaddress"}
Set-MailUser $usertargetaddress -EmailAddresses @{Remove="smtp:$usersourceaddress"}
Get-MailUser $usertargetaddress | fl DisplayName,PrimarySMTPAddress,ExchangeGuid,ArchiveGuid
}

Now we’ve created the MailUser objects for our batch, we can migrate them using the second file:

# create migrationbatch using csv
$batchfile = 'C:\temp\sourcedomain\batch1-users.csv'

New-MigrationBatch -Name $batchname -SourceEndpoint 'sourcedomainMigEndpoint' -CSVData ([System.IO.File]::ReadAllBytes("$batchfile")) -Autostart -TargetDeliveryDomain 'targetdomain.onmicrosoft.com'

This migration went really smoothly… once the user was migrated, the forwarding was set correctly in the source tenant, and permissions were intact for access to Shared Mailboxes etc. Now we are planning to tidy up and move additional email aliases, the accepted domains and MX records across to the new tenant… a far less perilous journey I hope, but really I’m impressed with how relatively easy this was!

Until next time… ‘chur to the chur’ from New Zealand! Hei kone ra 🍻😜

Have you secured your IIS Web Server?

Cloud services have improved our lives and made our jobs easier – BUT they have also given hackers a worldwide platform of unlimited power with which to attack us… very sad but very true!

😲 😲 😲 😲 😲 😲 😲 😲

This makes it even more critical to secure our external-facing services as much as we can.

Hopefully you have a WAF in front of your web server, but if you are like me and have a small site that does not justify the associated costs of advanced protection, here are some basic steps to take on your Windows Server. Note that ‘Strict High Transport Security’ (step 4) is available from IIS 10 in 2019 Server.

If you’re an IT nerd like me, you just gotta be happy with a result like this from https://www.ssllabs.com/ssltest…

SSL Labs test site

NOTE: When you run the test, remember to check the box if you do not want the result to be displayed on the page…

👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍

OK – to business! There are four initial steps we can perform on a personal or small business web server that is exposed to the interwebs… resulting in an A+ score from an SSL test. (I’m assuming you already have a site with port 80 and 443 allowed inbound).

  1. Apply a Lets Encrypt certificate.

a) they are free!

b) they have a great reputation.

c) they are so easy to install it is not even funny!

Simply go to https://www.win-acme.com, click Downloads and grab the latest version. Extract to c:\program files\win-acme. Run wacs.exe and follow the recommended prompts… this automatically creates and applies a certificate that is valid for 3 months, then sets up a scheduled task to check and automatically renew the cert. Boom! Refer to https://www.win-acme.com/manual/getting-started.

2. Secure the protocols…

Open PowerShell ISE (run as admin), paste and run the code below to confirm TLS 1.0 and 1.1 are disabled and TLS 1.2 is enabled for the system and .NET:

# disable TLS 1.0 and 1.1
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server' -name 'Enabled' -value '0' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server' -name 'DisabledByDefault' -value 1 -PropertyType 'DWord' -Force | Out-Null
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Client' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Client' -name 'Enabled' -value '0' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Client' -name 'DisabledByDefault' -value 1 -PropertyType 'DWord' -Force | Out-Null
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Server' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Server' -name 'Enabled' -value '0' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Server' -name 'DisabledByDefault' -value 1 -PropertyType 'DWord' -Force | Out-Null
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Client' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Client' -name 'Enabled' -value '0' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.1\Client' -name 'DisabledByDefault' -value 1 -PropertyType 'DWord' -Force | Out-Null

# enable TLS 1.2 for .NET
New-Item 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -name 'SystemDefaultTlsVersions' -value '1' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\v4.0.30319' -name 'SchUseStrongCrypto' -value '1' -PropertyType 'DWord' -Force | Out-Null
New-Item 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -name 'SystemDefaultTlsVersions' -value '1' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SOFTWARE\Microsoft\.NETFramework\v4.0.30319' -name 'SchUseStrongCrypto' -value '1' -PropertyType 'DWord' -Force | Out-Null

# enable TLS 1.2 for system
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null
New-Item 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'Enabled' -value '1' -PropertyType 'DWord' -Force | Out-Null
New-ItemProperty -path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client' -name 'DisabledByDefault' -value 0 -PropertyType 'DWord' -Force | Out-Null

Write-Host -ForegroundColor Green 'TLS 1.0 and 1.1 disabled. TLS 1.2 enabled.'

3. Disable insecure ciphers…

In a new ISE tab, paste the following code to disable weak ciphers (some commands may fail but that’s okay):

Disable-TlsCipherSuite -Name "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384" | Out-Null
Disable-TlsCipherSuite -Name "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_RSA_WITH_AES_256_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_RSA_WITH_AES_128_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_256_GCM_SHA384" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_128_GCM_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_256_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_128_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_256_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_AES_128_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_3DES_EDE_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_DSS_WITH_AES_256_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_DSS_WITH_AES_128_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_RC4_128_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_RC4_128_MD5" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_NULL_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_RSA_WITH_NULL_SHA" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_AES_256_GCM_SHA384" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_AES_128_GCM_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_AES_256_CBC_SHA384" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_AES_128_CBC_SHA256" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_NULL_SHA384" | Out-Null
Disable-TlsCipherSuite -Name "TLS_PSK_WITH_NULL_SHA256" | Out-Null

Write-Host -ForegroundColor Green "Weak ciphers disabled."

4. Enable HTTP Strict Transport Security

a) In IIS Manager, open the HTTP Response Headers section.

b) Click Add.

c) In the Name field, add “Strict-Transport-Security“.

d) In the Value field, add “max-age=31536000” (this corresponds to a one year period validity).

d) Click OK.

Oh and don’t forget to redirect port 80 to 443… even though the above step effectively forces the browser to use HTTPS, there is no harm doing it with the URL Rewrite feature (I use this so that I can also block connections to my WordPress admin page).

Here is the rule I use at the web site level to redirect any HTTP request to HTTPS:

Superbulous! Now run the test, grab a cup of tea and a biscuit and pat yourself on the back for being so awesome!! 😎 😎 😎

Over and out until next time! Cheers 🍻

Runbook: Sync Shared Mailbox accounts with an Azure AD Group

Hey! I hope you are well.. 🤘 🙂 🤘. This script was a result of the following ponderings:

  • How to monitor and manage the deletion of Blocked (Disabled) and Guest accounts in Azure AD.
  • I have a Dynamic group for ‘Blocked (Disabled) users’, but members include valid Shared Mailbox accounts.
  • What about Guest users… should I just leave them? 🤣

Noooo, I shouldn’t… paying monthly subscriptions it’s important to stay on top of user account maintenance. There are some reports and sorting you can do, and Power BI, Graph etc, but I wanted to script something!! In my usual non-perfect PowerShell way of course, but hey it gets the job done.

The Guest users are easy to group using a Azure AD Dynamic Security group with this Rule Syntax:

(user.userType -eq "Guest") and (user.accountEnabled -eq true)

Sweet! My Blocked (or Disabled) users group is Dynamic as well, using this syntax:

(user.accountEnabled -ne true) and (user.surname -ne "Shared_Mailbox")

I’ve only just added (user.surname -ne “Shared_Mailbox”) – the script sets that attribute when it adds an account to the Shared Mailbox group, so that the accounts are excluded from the Dynamic Bocked Users group. Cool now I can actually review the blocked users knowing my Shared Mailbox accounts are safe!

I could also use Conditional Access policies to increase the security of those accounts!

Here is the script… you need to have an Automation account with credential set up (this can be a Synced AD or cloud account that has ‘Exchange Recipient’ and ‘Group Administrator’ roles assigned. Make sure you have imported the AzureAD and ExchangeOnlineManagement modules into the Automation account, and have created the Azure AD Group (set the group to ‘Assigned’ membership rather than ‘Dynamic’). From Azure AD navigate to Groups, search for your group and click on it. You will be able to copy the Object ID from here:

Enter that for $sharedmailboxgroupid and the ‘Name’ of your Automation account credential as $runbookcredentialname. That’s it – give it a good testing and whack eem into production mate!

(NB – following all relevant change control precedures of course!)

See below the code for how the output looks in the Runbook logs… great for troubleshooting!

See ya! 🍺

# use TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Sync shared mailbox accounts with an Azure AD group - Simon Burbery - November 2021
# Update variables with the name of your runbook credential and the Azure AD Object ID displayed on the groups overview page in the AAD portal.

# set variables
$runbookcredentialname = 'svc_runbookcredential'
$sharedmailboxgroupid = '12345678-abcd-4321-0987-665544332211'

# get credential for connections
Try { 
    $CredAzure = Get-AutomationPSCredential -Name $runbookcredentialname
}
        Catch {
            Write-Error "Failed to get credential!"
            Exit
        }   
Write-Output "Get automation credential - Success"

# connect Azure AD
Try {
    Connect-AzureAD -Credential $CredAzure | Out-Null
}
        Catch {    
            Write-Error "Failed to connect to Azure AD!"
            Exit
        }
Write-Output "Connect to Azure AD - Success"

# connect EOL
Try {
    Connect-ExchangeOnline -Credential $CredAzure
}
        Catch {    
            Write-Error "Failed to connect to EOL!"
            Exit
        }
Write-Output "Connect to EOL - Success"

# get group name
$groupname = (Get-AzureADGroup -ObjectId $sharedmailboxgroupid).DisplayName

# get all shared mailboxes and group members
Write-Output "Enumerating Shared Mailbox accounts and $groupname membership..."
Try {
    $sharedmailboxaccounts = Get-Mailbox -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq 'SharedMailbox' } | select ExternalDirectoryObjectID,UserPrincipalName
    $currentgroupmembers = Get-AzureADGroupMember -All $true -ObjectId $sharedmailboxgroupid | select ObjectID,UserPrincipalName
}
        Catch {    
            Write-Error "Failed to enumerate Shared Mailbox accounts or $groupname membership!"
            Exit
        }
Write-Output "Enumerate Shared Mailbox accounts and $groupname membership - Success"

# remove any members that are no longer shared mailboxes
Write-Output "Verify $groupname membership..."
Try {
    foreach ( $groupmember in $currentgroupmembers ) {
        $groupmemberid = $groupmember.ObjectID
        $groupmemberupn = $groupmember.UserPrincipalName
        $checkmember = ( $sharedmailboxaccounts.ExternalDirectoryObjectId -contains $groupmemberid )
            If ( $checkmember -ne 'True' ) {
                Write-Output "Shared Mailbox not found - removing $groupmemberupn from $groupname..."
                Remove-AzureADGroupMember -ObjectId $sharedmailboxgroupid -MemberId $groupmemberid
                Set-AzureADUser -ObjectId $groupmemberid -Surname 'Disabled User'
            }
                Else {
                    Write-Output "Shared Mailbox found - skipping $groupmemberupn"
                }
    }
}
        Catch {    
            Write-Error "Error while removing accounts from group!"
            Exit
        }
Write-Output "Verify $groupname membership - Success"

# add new shared mailbox accounts to Azure AD group
Write-Output "Checking for new Shared Mailboxes..."
Try {
    foreach ( $sharedmailboxaccount in $sharedmailboxaccounts ) {
        $sharedmailboxaccountid = $sharedmailboxaccount.ExternalDirectoryObjectId
        $sharedmailboxaccountupn = $sharedmailboxaccount.UserPrincipalName
        $checkmembersm = ( $currentgroupmembers.ObjectID -contains $sharedmailboxaccountid )
            If ( $checkmembersm -ne 'True' ) {
                Write-Output "New Shared Mailbox - adding $sharedmailboxaccountupn to $groupname..."
                Add-AzureADGroupMember -ObjectId $sharedmailboxgroupid -RefObjectId $sharedmailboxaccountid
                Set-AzureADUser -ObjectId $sharedmailboxaccountid -Surname 'Shared_Mailbox'
            }
                else {
                    Write-Output "Skipping Shared Mailbox $sharedmailboxaccountupn"
                }
    }
}
        Catch {    
            Write-Error "Error while adding accounts to group!"
            Exit
        }
Write-Output "Check for new Shared Mailboxes - Success"

# clean up
Disconnect-ExchangeOnline -Confirm:$false
Disconnect-AzureAD -Confirm:$false

# end

And here is what I really like about using Runbooks – the output from the script is available to go back and look at when failures occur etc. Nice!

Remotely trigger delta AD Connect sync!

How often do you RDP to the AD Connect server to run a Delta Sync?

Yes I know, quite often right? And that is only once you find out which server it is running on. Especially If you are in new environments a lot or someone moved it since last time… sheesh thanks for telling us Dave!! 😭🤣

This script can be run from any Windows 10, 2016 or later endpoint… it will attempt to get the servername from AD then connect remotely and run a delta sync (we do some checks and have some messaging if things fail).

🙂👍 🙂

NOTE: Update 13/12/21 – when finding the AD Connect server, if you’ve already had more than one and someone hasn’t deleted the old computer account, both names will be returned causing the script to fail. Just run that bit of code first and delete any old accounts from the domain (or just replace the code with the server name)

First thing we do is run the following commands in an elevated PowerShell prompt to add the AD PowerShell module:

Install-PackageProvider Nuget -Force #justbecause

For Windows 10/11:
Add-WindowsCapability –online –Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0

For Windows 2016/2019:
Install-WindowsFeature RSAT-AD-PowerShell -Confirm:$false

Next, let’s make this easy to run with elevated rights by copying the script text into notepad and saving it into the c:\_scripts folder as “Force AD Connect Sync.ps1”

Then create a “Force AD Connect Sync.cmd” on your desktop with the following in it:

start powershell.exe -ExecutionPolicy Bypass -File "c:\_scripts\Force AD Connect Sync.ps1"

Now we can right-click on the cmd file and click ‘Run as Administrator”. Does the trick and time is life!

To find the server we use a method from easy365manager, and the link for enabling remoting is from faqforge. Thanks peoples!

Easy365manager:
https://www.easy365manager.com/how-to-identify-your-azure-ad-connect-server/

Faqforge:
https://www.faqforge.com/windows/create-powershell-session-remote-computer/

Here is the script – let me know if it worked or if it sucked and how you made it better! Until next time! Cheers, Simon 🍺 …oh and PS – if you want a great rundown on AD Connect, check out Adam’s post:

https://adamtheautomator.com/azure-ad-connect/#Install_Azure_AD_Connect

# force a delta sync to Azure AD

# load AD module
Try {
    Import-Module ActiveDirectory
}
    Catch {
        Write-Warning "Encountered a problem importing AD module."
        Write-Host
        Read-Host "Press Enter to exit..."
        Exit
    }
Write-Host -ForegroundColor Green "AD module loaded successfully."
Write-Host

Try {
    $ADConnectServer = Get-ADUser -LDAPFilter "(description=*configured to synchronize to tenant*)" -Properties description | % { $_.description.SubString(142, $_.description.IndexOf(" ", 142) - 142)}
}
    Catch {
        Write-Warning "Encountered a problem obtaining name of AD Connect server."
        Write-Host
        Read-Host "Press Enter to exit..."
        Exit
    }

Write-Host -ForegroundColor Green "Found AD Connect server $ADConnectServer!  Testing connection..."
Write-Host

Try {
    $session = New-PSSession -ComputerName $ADConnectServer -Authentication Default
    Enter-PSSession $ADConnectServer
}
    Catch {
        Write-Warning "Cannot connect to $ADConnectServer, please check remote connectivity." 
        Write-Warning "ref - https://www.faqforge.com/windows/create-powershell-session-remote-computer/"
        Write-Host
        Read-Host "Press Enter to exit..."
        Exit
    }

Write-Host -ForegroundColor Green "Connected to $ADConnectServer - Forcing a delta sync... one moment!"
Write-Host

Try {
    Start-ADSyncSyncCycle -PolicyType Delta
}
    Catch {
        Write-Warning "The command failed - either a sync is already in progress," 
        Write-Warning "or you are not a member of the 'ADSyncAdmins' group on the AD Connect server."
        Write-Host
        Read-Host "Press Enter to exit..."
        Exit
    }

Write-Host -ForegroundColor Green "Sync started successfully!"
Write-Host
Read-Host "Press Enter to exit..."

# clean up
Exit-PSSession
Remove-PSSession $session

Remove proxy address for specific domain from Exchange Distribution groups

This script can be run after connecting to Exchange Online or on-premises environment. Replace “porkchops.com” with the suffix you want to remove 👍

Thanks to me mate Sailesh who loooves his porkchops!! 🤣🤣

# Remove proxy address for "porkchops.com" from Exchange Distribution groups

$domainname = "porkchops.com"

$groups = Get-DistributionGroup -Resultsize unlimited | where {$_.EmailAddresses -like "*$domainname*"} 

foreach ($group in $groups) {  

    $groupidentity = $group.identity
    $addresstoremove = $group.Alias+"@$domainname"

    Set-DistributionGroup $groupidentity -EmailAddresses @{remove=$addresstoremove}
}

# End

Exchange Online – set default Retention Policy if null

# EDIT # I have updated this script due to an issue where multiple mailboxes are matched due to similar names, this line below with $mailboxes variable piped to the Set command uses Display Name for Identity which may not be unique. Script is updated to loop through the mailboxes using UPN for the Set command. Cheers! 🍺

$mailboxes | Set-Mailbox -RetentionPolicy $defaultpolicy.Name

I’ve come across several clients lately who are migrating to or have migrated to Exchange Online, and find some users have no retention policy set. This script can be scheduled in an Azure runbook to find enabled users with no policy and set it to the default policy. Replace ‘svc-runbookcred’ with your runbook credential name. Easily modified to connect to on premise Exchange; if you need any help just add a comment below! 🙂

# use TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# specify runbook credential name
$runbookcredential = 'svc-runbookcred'

# get credential for eol connection
Try { 
    $CredAzure = Get-AutomationPSCredential -Name $runbookcredential
}
        Catch {
            Write-Error "Failed to get credential!"
            Exit
        }   
Write-Output "Get automation credential - Success"

# connect eol
Try {
    Connect-ExchangeOnline -Credential $CredAzure
}
        Catch {    
            Write-Error "Failed to connect to MSOnline!"
            Exit
        }
Write-Output "Connect to EOL - Success"

# get default policy from org settings
Try {
    $defaultpolicy = Get-RetentionPolicy | Where-Object { $_.IsDefault -eq $true }
}
        Catch {    
            Write-Error "Failed to get default policy!"
            Exit
        }
Write-Output "Get default policy - Success"

# find enabled mailboxes with no policy set
Try {
    $mailboxes = Get-Mailbox -ResultSize Unlimited -Filter { ( RecipientTypeDetails -eq 'UserMailbox' ) -and ( ExchangeUserAccountControl -ne 'AccountDisabled') } | Where-Object { $_.RetentionPolicy -eq $null }
}
        Catch {    
            Write-Error "Failed to get mailboxes!"
            Exit
        }
Write-Output "Get mailboxes - Success"

# set to default policy
Try {
    foreach ($mailbox in $mailboxes) {
        Set-Mailbox -Identity $mailbox.UserPrincipalName -RetentionPolicy $defaultpolicy.Name
    }
}
        Catch {    
            Write-Error "Failed to set policy!"
            Exit
        }
Write-Output "Set default policy - Success"

# end

Azure Runbook – enable Exchange Online Litigation Hold

This script will connect to Exchange Online and enable litigation hold for all enabled users. Errors due to not having the appropriate license are ignored. Litigation hold can be enabled for users licensed with Business Premium, EOL Plan 2 or the Mailbox Archive add-on. Replace ‘svc-runbookcred’ with your runbook credential name. You can schedule to run nightly to pick up new users as they are added. If you like the script, made it cooler or need some help, please add a comment below! 🙂

# use TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# specify runbook credential name
$runbookcredential = 'svc-runbookcred'

# get credential for eol connection
Try { 
    $CredAzure = Get-AutomationPSCredential -Name $runbookcredential

}
        Catch {
            Write-Error "Failed to get credential!"
            Exit
        }   
Write-Output "Get automation credential - Success"

# connect eol
Try {
    Connect-ExchangeOnline -Credential $CredAzure
}
        Catch {    
            Write-Error "Failed to connect to MSOnline!"
            Exit
        }
Write-Output "Connect to EOL - Success"

# get user mailboxes
Try {
    $mailboxes = Get-Mailbox -ResultSize Unlimited -Filter { ( RecipientTypeDetails -eq 'UserMailbox' ) -and ( ExchangeUserAccountControl -ne 'AccountDisabled') } | Where-Object {$_.LitigationHoldEnabled -ne $true}
}
        Catch {    
            Write-Error "Failed to get user mailboxes!"
            Exit
        }
Write-Output "Get user mailboxes - Success"

# enable litigation hold
Try {
    $mailboxes | Set-Mailbox -LitigationHoldEnabled $true -ErrorAction Ignore
}
        Catch {    
            Write-Error "Failed to enable litigation hold!"
            Exit
        }
Write-Output "Enable litigation hold - Success"

Azure Runbook – Licensing Alert

I created this script for a client that wanted to know when they had no available licenses for any SKU.  I’m sure they will add this to the portal soon (?)

The goal is simple – if my consumed no. of licenses = available licenses for any given SKU, send an email to me and my CSP so I can replenish before it becomes a problem.  Easily modified to alert at any number of remaining available licenses. e.g. to alert when there are 5 available licenses change ($_.ConsumedUnits -eq $_.ActiveUnits) to ($_.ConsumedUnits -eq $_.ActiveUnits-5).

The script is written to run as an Azure PowerShell Runbook, which allows use of a credential stored in the automation account, as well as using output to have some nice text show up in the portal logs.  I’m assuming you have set this stuff up already (if you haven’t, google it and get it sorted =). I’ll do a post soon on how to do it but it is not too difficult.

Azure blocks outbound connections on port 25, so no going there!  But aha, they do allow secure port 587.  So I use a ‘soon to be’ deprecated command called send-mailmessage to send the email using a free SendGrid account (no cost for 100 emails per month) which is plenty enough for this solution.

Disclaimer ## as I was testing the script, I noticed Azure now has a ‘SendGrid solution’ where you can sign up to SendGrid free from within the Azure portal – awesome!  Shame I missed it… if I update to using that method I will update this post =).  My understanding is that you could sign up for that, then use it by calling a ‘playbook’ from the automation script.

Here is the script (replace $smtppswd with your sendgrid API key SG.xxxxxx, replace $runbookcredentialname with your runbook cred name, replace $mailfrom and $mailto).  Also, since there may be 0 available licenses or 1000 freely available licenses, by default I’m only considering available license values >1 <500.  Change to suit your needs!

If you have any problems or made the script cooler (like sending the info in an HTML table) please add a comment below! 🙂

# use TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# check for any licenses out of stock and send a notification - Simon Burbery - August 2021

# create credential for sending email via SendGrid
$smtpuser = 'apikey'
$smtppswd = ConvertTo-SecureString -String 'SG.xxxxxxx' -AsPlainText -Force
$CredSMTP = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $SMTPuser, $SMTPpswd

# set variables
$runbookcredentialname = 'svc_runbookaccount'
$mailfrom = 'Azure License Notifcation <sendmail@place.co.nz>'
$mailto = @("<admin@place.co.nz>", "<azurealerts@place.co.nz>")
$mailsubject = 'Warning - out of licenses!'
$mailbody = 'Availability of one or more of your license SKUs has reached zero:'
$mailserver = 'smtp.sendgrid.net'
$mailport = '587'
$mailcredential = $CredSMTP

# get credential for msol connection
Try { 
    $CredAzure = Get-AutomationPSCredential -Name $runbookcredentialname
}
        Catch {
            Write-Error "Failed to get credential!"
            Exit
        }   
Write-Output "Get automation credential - Success"

# connect msol
Try {
    Connect-MsolService -Credential $CredAzure
}
        Catch {    
            Write-Error "Failed to connect to MSOnline!"
            Exit
        }
Write-Output "Connect to MSOL - Success"

# license check
$skucheck = Get-MsolAccountSku  | Where-Object { ($_.ActiveUnits -gt 0) -and ($_.ActiveUnits -lt 500) -and ($_.ConsumedUnits -eq $_.ActiveUnits) }

# email body format
$mailbodyfinal = $mailbody,$skucheck | Out-String -Width 500

# send notification
If ( $skucheck -ne $null ) {
    $MailParameters = @{
        From = $mailfrom
        To = $mailto
        Subject = $mailsubject
        Body = $mailbodyfinal
        SmtpServer = $mailserver
        Port = $mailport
        Credential = $CredSMTP
        UseSsl = $true
        }
        Send-MailMessage @MailParameters
            If  ($? -ne $true) { 
    Write-Error "Failed to send email notification!" 
    }
                Else {
                Write-Output "Send email notification - Success"
            }       
}
    Else {
        Write-Output "No licensing issues detected"
    }

# end

Azure AD – export groups and members to CSV

# export azure ad groups and members to csv (also output empty groups with 'No Members' value) 
# assumes existing connection to Azure AD using Connect-AzureAD (or use a runbook)

$allgroups = Get-AzureADGroup | select ObjectId,DisplayName

$result = foreach ( $group in $allgroups ) {

    $hash = @{GroupName=$group.DisplayName;Member=''}
    $groupid = $group.ObjectId
    
    if ( $members = Get-AzureADGroupMember -ObjectId $groupid ) {
            
            foreach ( $member in $members ) {

                $hash.Member = $member.DisplayName
                New-Object psObject -Property $hash
            }
            }
    else
        {
        $displayname = "No Members"
        $hash.Member = $displayname
        New-Object psObject -Property $hash
        }
}

$result | Export-Csv -Path C:\temp\AzureADGroups.csv -NoTypeInformation

# End

PowerShell get azure ad group members export to csv

export azure ad group members to csv PowerShell

PowerShell export azure ad user group membership to csv

Export Active Directory groups and members to CSV file

UPDATE 2022-04-02 – if you would like email addresses with the output, check out my new post at: https://www.howdoiuseacomputer.com/index.php/2022/04/02/export-active-directory-groups-and-members-to-a-csv-file-with-email-addresses/

# export active directory groups and members to csv (also output empty groups with 'No Members' value)
# assumes run on domain controller or import of ActiveDirectory module

$allgroups = Get-ADGroup -Filter *

$result = foreach ( $group in $allgroups ) {

    $hash = @{GroupName=$group.SamAccountName;Member=''}
    $groupid = $group.distinguishedname
    
    if ( $members = Get-ADGroupMember $groupid ) {
            
            foreach ( $member in $members ) {

                $hash.Member = $member.Name
                New-Object psObject -Property $hash
            }
    }
    else {
        $displayname = "No Members"
        $hash.Member = $displayname
        New-Object psObject -Property $hash
    }
}

$result | Export-Csv -Path C:\temp\ActiveDirectoryGroupsAndMembers.csv -NoTypeInformation

# End