GAL separation with Address Book Policies

Just a quick one on ABPs – there are many posts about this topic now, but there are few that mention how to resolve issues such as recipients not appearing in the list when they should be. In this case you need to ‘tickle’ (yes that’s the official Microsoft term) the objects to get them to play ball.

ABPs seemed complex when I first looked at them, and my first introduction was with a tenant that had 20,000 user objects! We don’t get many opportunities to work with environments of this size in New Zealand, so it was a great job to get involved with. #dontmessitupmaaate!

ABPs are most commonly used in large environments though, or where separation is needed. Examples of this are:

  • Multiple schools under one tenant – you don’t want students from one school seeing students from other schools in the Global Address List.
  • Multiple companies under one tenant. You don’t want Fabrikam users seeing Contoso users.
  • In both of these scenarios, you may want management or executive level staff to see all recipients in their GAL.

Here is the code to create each ABP… I’m using my fictional company SB Enterprises which exists in a large multi-company tenant. Customattribute1 is used across the tenant to identify the objects related to a particular company with a three-letter-acronym. (this could be the Company attribute, or any other attribute as long as we can use it for filtering in the commands). For Meeting Room, Equipment, or Contact objects, those are created with the company TLA at the front e.g. SBE_MeetingRoom1.

Firstly let’s connect to Exchange Online powershell:

Connect-ExchangeOnline -UserPrincipalName insertadminupnhere

Now let’s create some address lists to include in our policy:

# this one contains all our recipients (users, groups, and shared mailboxes)
New-AddressList -Name "SBE_AddressList" -RecipientFilter "(CustomAttribute1 -eq 'SBE')"

# this one contains our groups
New-AddressList -Name "SBE_Groups" -RecipientFilter "(ObjectClass -like 'group') -and (CustomAttribute1 -eq 'SBE')"

# this one contains our shared mailboxes
New-AddressList -Name "SBE_Shared Mailboxes" -RecipientFilter "(RecipientTypeDetails -eq 'SharedMailbox') -and (CustomAttribute1 -eq 'SBE')"

# this one contains our rooms
New-AddressList -Name "SBE_Rooms" -RecipientFilter "(RecipientTypeDetails -eq 'RoomMailbox') -and (Name -like 'SBE_*')"

# this one contains our contacts
New-AddressList -Name "SBE_Contacts" -RecipientFilter "(RecipientType -eq 'MailContact') -and (Name -like 'SBE_*')"

Sweet as lemon pie! Now let’s get a list of all the objects that should be included in each list:

$filter = (Get-AddressList "SBE_AddressList").recipientfilter
Get-Recipient -ResultSize unlimited -RecipientPreviewFilter $filter | Out-GridView

$filter = (Get-AddressList "SBE_Groups").recipientfilter
Get-Recipient -ResultSize unlimited -RecipientPreviewFilter $filter | Out-GridView

$filter = (Get-AddressList "SBE_Shared Mailboxes").recipientfilter
Get-Recipient -ResultSize unlimited -RecipientPreviewFilter $filter | Out-GridView

$filter = (Get-AddressList "SBE_Rooms").recipientfilter
Get-Recipient -ResultSize unlimited -RecipientPreviewFilter $filter | Out-GridView

$filter = (Get-AddressList "SBE_Contacts").recipientfilter
Get-Recipient -ResultSize unlimited -RecipientPreviewFilter $filter | Out-GridView

Looking good? Now let’s create the Global Address List and the Offline Global Address List:

# the global address list has it's own filter for all SBE objects
New-GlobalAddressList -Name "SBE_GlobalAddressList" -RecipientFilter "(CustomAttribute1 -eq 'SBE') -or (Name -like 'SBE_*')"

# the offline address list includes the address lists we created
New-OfflineAddressBook -Name "SBE_OfflineAddressList" -AddressLists "SBE_AddressList","SBE_Groups","SBE_Shared Mailboxes","SBE_Rooms","SBE_Contacts"

Great! Now we can create the Address Book Policy using all of the above:

New-AddressBookPolicy -Name "SBE_ABP" -AddressLists "SBE_AddressList","SBE_Groups","SBE_Shared Mailboxes","SBE_Contacts" -OfflineAddressBook "\SBE_OfflineAddressList" -GlobalAddressList "\SBE_GlobalAddressList" -RoomList "\SBE_Rooms"

Done! Well almost – we have to assign it to the users… I recommend assigning to a pilot group first to get some feedback in case of any issues. When ready, use this command to assign the ABP to all applicable users:

$SBE_ABP = Get-Mailbox -ResultSize unlimited -Filter "(RecipientTypeDetails -eq 'UserMailbox') -and (CustomAttribute1 -eq 'SBE')"; $SBE_ABP | foreach {Set-Mailbox -Identity $_.Identity -AddressBookPolicy 'SBE_ABP'}

Now, there are several things that can make it seem like things are not working, but I can tell you 99% of the time you just have to wait. It’s the old ‘cloud time phenomenon’ where things may take from 1 to 48 hours to take effect 🤣🤣🤣.

The most common issue I have come across is someone in the pilot group pointing out that someone or something is missing from the GAL. This is due to the object not being processed when the Address List was created. It should be a member of the filter; and it is upon object creation or update that membership of address list filters is determined.

This is where ‘tickling’ comes in. You need to change something i.e. any attribute of the offending object, then change it back again.

You can do this in the portal for a single object (e.g. change the last name one letter, save, then change it back again)… but seeing as I know this problem exists, I now do this as part of the initial setup so I don’t have to deal with it later.

Let’s run this to change an attribute – I’ve checked all objects Customattribute5 is blank, so I can use it for this purpose (you don’t have to use tickle obviously, any value will do):

# get the users we want to 'tickle'
$users = Get-User -ResultSize unlimited -Filter "Customattribute1 -eq 'SBE'"

# tickle them by modifying a value (make sure the value was null for all objects beforehand)
foreach ($user in $users) {

    $id = $user.DistinguishedName
    Set-Mailbox $id -CustomAttribute5 "tickled"

# then return the value to null
foreach ($user in $users) {

    $id = $user.DistinguishedName
    Set-Mailbox $id -CustomAttribute5 $null

And voila, the object now appears on the list (taking into account the ‘cloud time phenomenon’ mentioned above).

As always, thanks for coming dudes & dudettes! ✌🍻✌


Why aren’t you using Azure DNS yet?

The only answers to this question are:

  • I’m already using AWS Route 53 and it’s better!
  • I’m lazy and I like things to take time and cost more, so I pay for someone else to take days to make a change for me [yawhaaat?]
  • I use Cloudflare because someone gave me food and beer (and showed me dnsdumpster) at a sales pitch!
  • What is DNS? In which case please follow this link – https://www.google.com/search?q=funny+cat+videos

I’m starting off-topic (it does say Ramblings at the top of the screen after all) but stay with me!


Hiding your DNS might seem like a good idea; I have nothing against as long as it is free, but to me it is like changing RDP port 3389 to 4444 (not that you have RDP directly exposed to the internet of course – you have an RD Gateway!). Sure, you’ve made it harder to identify an available target, but if someone is really after you they’ll have methods to get around this pretty quickly using port scans or other techniques. You’ve also made it harder for people trying to help to work out what is going on.

The best thing you can do is hurry up on that cloud journey so you can offload concerns about external IP addresses exposing your on-premises entry points. And as long as you are keeping those entry points secure and up to date, there shouldn’t be any major concern here anyway.

If you are running RDS or Citrix services, generally these do not work well with SSL inspection or pre-authentication so are configured to pass-through directly to the entry point i.e. Cloudflare is providing minimal benefit here.

Instead of being fancy pants and paying for these services to provide minimal benefit, let’s look at a great set up for minimal cost that let’s you easily add, remove or change DNS entries right in the portal you use everyday!

First, make sure your entry points are secure:

  • Ideally, make sure you have a Web Application Firewall in front of web facing services. This comes at a cost though, which some of us like to avoid if possible 😜
  • Whether you do or not, review the ciphers available and remove them from least secure upwards until you work out the minimal and most secure configuration. For Windows IIS, remove any ciphers you don’t need as described here: Secure your Web Server
  • Do you have an RD Gateway / Web server? The same applies with IIS, and make sure you don’t allow ‘Domain Users’ in the access policies. Refine this to a group that contains only the users that need access. I’m sure you can get by without ‘Administrator’ being available to brute force hack from outside.
  • Geo-blocking – use it! Most firewalls have some geo capability these days. If your users are in New Zealand and Australia, restricting access to those regions only at the firewall provides a huge security benefit.

Right what was I actually posting about? Ah yes – Azure DNS is easy to set up and costs literally a dollar and cents per month. You don’t transfer your records to Azure though as they are not (and may never be) a registrar. But no bother, once you’ve set up your records in Azure, you simply change the ‘nameserver’ configuration with your existing provider. I use Free Parking in New Zealand, a great low(ish) cost no-frills provider. About $45/year for a domain name and of course they have DNS management, but I’d rather do it in Azure so I after I configured the zone, I copied the four nameserver entries on the right:

…then changed my nameservers from Free Parking to Azure – there will be somewhere you can do this in your providers portal, or just log a request for them to do it:

Done! Once the update is complete you are now serving and managing your DNS entries from the familiar Azure portal.

Here is my Azure DNS zone cost… as you can see – cheap as cheeps mate! Yes I’ll pay a whopping NZD$1.58/month or $18.96/year. That’s only 5.42857 Steak ‘n’ Cheese from Mrs. Miggins pie shop! 🥧

The console is intuitive and I love the fact I can manage my DNS easily and securely from within my Azure tenant. I can also get some metrics about the DNS usage that I couldn’t get before:

Thanks Azure – you get better every day!

TTFN!! 🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻👀🍻


Exchange Online – merge a soft deleted mailbox into an active mailbox

Hi! hope you are well… I used this script yesterday and thought “I must post about that!” So here it is… 😃

This can be a good way to deal with an employee leaving, or if you end up with a dual-mailbox scenario, although Microsoft have made that much less likely since adding the Hybrid Exchange option to AD Connect a while back…

Right, enough kafuffle – onward! 👀

Suppose a user leaves – your off-boarding procedure may include something like:

  • Exporting the mailbox to a PST file which is stored somewhere
  • Converting the mailbox to a Shared Mailbox and giving a another user access to it

But what if there is an incoming user that would benefit from having the existing data in their newly created mailbox? It might be a training coordinator or HR administrator’s mailbox that has a heap of relevant information that the incoming user can search through for information. This is much easier to use when it is in your main mailbox, rather than an attached shared mailbox (or worse a PST file!).

This script will connect to Exchange Online and prompt you with a list of soft-deleted mailboxes (these are the ones that have been deleted but remain for 30 days before being permanently deleted). Select the recently deleted mailbox and click OK. Now you are prompted with a list of all active mailboxes. Select the mailbox to merge the data into and click OK. Now the soft-deleted mailbox will be merged into the active mailbox. Superb!

Make sure you have run ‘Install-Module ExchangeOnlineManagement‘ so you can run the script successfully.

Here’s the code:

# set variables to null
$source = $null
$target = $null

#Connect to EOL...
Try {
    Connect-ExchangeOnline -ShowBanner:$false
    Catch {
        Write-Warning "Failed to connect to Exchange Online!"
        Read-Host "Press a key to exit..."

# prompt to select source mailbox
$source = Get-Mailbox -SoftDeletedMailbox -ResultSize unlimited | Select DisplayName,ExchangeGuid,PrimarySmtpAddress,ArchiveStatus,DistinguishedName | Out-GridView -Title "Select source soft-deleted mailbox and click OK..." -PassThru

if ($source -eq $null) {
        Write-Warning "No source selected. Press any key to exit..."

# prompt to select target mailbox
$target = Get-Mailbox -ResultSize unlimited | Select Name,PrimarySmtpAddress,DistinguishedName | Out-GridView -Title "Select target mailbox and click OK..." -PassThru

if ($target -eq $null) {
        Write-Warning "No target selected. Press any key to exit..."

# start merging source into target
Try {
    New-MailboxRestoreRequest -SourceMailbox $source.DistinguishedName -TargetMailbox $target.PrimarySmtpAddress -AllowLegacyDNMismatch
    Catch {
        $errormsg = $Error[0].Exception.Message
        Write-Warning "An error occured. $errormsg. Press any key to exit..."

As usual please let me know if you have any trouble or have a scenario that warrants a modified solution and I’ll try to help.

Until next time! 🍻✌😎✌🍻


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:


PLEASE 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 mail-enabled security group in the source tenant Exchange Admin console, ‘zzMigUsers@sourcedomain.com’ should do the trick! ..then add the users (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 below code bits in, then save it for later. First, we need our commands to be able to switch quickly between tenants (disconnect before connecting each time):

# 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 to set up the App Registration and Enterprise App, easy as!): Target tenant app setup.

# 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"
$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"
$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 – you should see the expected values returned:

# 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-OrganizationRelationship | fl name, DomainNames, MailboxMoveEnabled, MailboxMoveCapability
Test-MigrationServerAvailability -Endpoint "SourceDomainMigEndpoint"

Awesome, green lights!? The tenants are configured. Now disconnect / connect to 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 (yes I know we could have done that earlier =)). Limit this if required based on the group we created earlier, scoped to your migration users.

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

Disconnect / connect to the target tenant again. 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'

NOTE: Recently I found this command was not working and I had to use a one-liner copy of the batch1-users.csv mentioned below. After the test user is successfully migrated, reference your original file with all the mailboxes in it. Just in case you get this issue in your scenario 😃

Okay! When this is working and we are confident all is well, copy the sourceusers.csv to a new file named batch1-detail.csv. Open the file and add three columns before DisplayName as below. ‘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-detail.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 from batch1-detail.csv into the EmailAddress column and populate the other columns as below:

Now you can use this code to create the MailUser objects with the batch1-detail.csv file:

# UPDATE THESE CORRECTLY - set variables and import csv
$csv = Import-Csv C:\temp\sourcedomain\batch1-detail.csv

foreach ( $line in $csv ) {

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

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

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'

NOTE: At this point you are migrating the mailboxes because of the “-Autostart” switch. Users in the batch will have ‘Syncing’ status. When ready to complete they will be ‘Synced’.

You can run this command to check the status of a batch:

# Get status of a migration batch:
Get-MigrationBatch $batchname | fl

Or this to check status of a single user:

# Get details for a single mailbox
Get-MigrationUser 'insertupnhere' | Get-MigrationUserStatistics | fl

Or this to get the status of all migration users, updated every 5 minutes:

# output progress of all users every 5 mins
$x = 0
$x = $x + 1
Get-MigrationUser | Get-MigrationUserStatistics

Start-Sleep -Seconds 300
Until ( $x -eq 480 )

When the mailboxes in a batch have ‘Synced’ status, go ahead and complete the batch using this command:

# Complete a Synced batch
Complete-MigrationBatch $batchname -Confirm:$false

This can take a bit of time so have a beer or cup of tea then come back – done! Once completed the mailuser you created in the target tenant will become a mailbox, and the mailbox in the source tenant will become a mailuser with a forwarding address set to the users target onmicrosoft.com address 👍

Some other commands I used are:

# Restart a failed batch
## Start-MigrationBatch $batchname

# remove a completed batch
## Remove-MigrationBatch $batchname -Confirm:$false

# Remove failed users from batches due to failure
## Get-MigrationUser | ? { $_.status -eq 'failed' } | Remove-MigrationUser -Confirm:$false

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… I’m impressed with how relatively easy this was!

Until next time… ‘Hei kone ra’ from New Zealand Aotearoa! 🍻😜


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. Note that this is ideally run from the web server with traffic allowed inbound on port 80 and 443 (you can redirect 80 to 443, but 80 does need to be open for LetsEncrypt to work without manual intervention – AFAIK 😃).

  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!

Go to win-acme.com, click downloads and grab the latest version. Extract to c:\program files\win-acme. Run wacs.exe and follow the prompts… you can manually specify hostname, additional SAN names if required, or generate a wildcard. Use the default in memory validation; this creates a virtual directory (which is in memory and removed afterwards) in IIS for LetsEncrypt to connect to to verify the request. The app then automatically creates and applies a certificate that is valid for 3 months, then sets up a scheduled task to automatically renew the cert before expiry. 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… (Windows 2019+)

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:

EDIT 02/07/22: totes forgot about step 5 – CAA (Certificate Authority Authorization) records which I enabled on this site. I use namecheap.com who offer these records free with domain name registration. Most DNS providers should support CAA records, if not, well… switch providers I say! 😎 Here is a screenshot of my records:

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 🍻


Please do NOT disable Security Defaults!

If you aren’t licensed for and using Conditional Access policies, please do not disable the security defaults feature just because something isn’t working (e.g. scan to email). Microsoft introduced the defaults for a very good reason – they realised that tenants without Azure AD Premium P1 licensing and correctly configured CA policies were wide open to Phishing and Password Spray attacks, via connections to Exchange Online using basic authentication protocols such as POP, IMAP and SMTP.

Connections using basic authentication do not support and therefore bypass MFA. If you disable this setting you are effectively turning off many security features.

Let’s find a solution to these problems and leave our tenant protected ‘by default’.

Here’s how to allow certain things (e.g. Teams meeting room devices and printers) while leaving our tenant secure:

1. Add any external IPs of company locations to Trusted IPs under MFA settings. In most cases you would do this for all company owned office locations. 


2. Set Password Reset Registration to No so that new users are not prompted to register.


3. If you need to send SMTP email through Exchange Online (e.g. from a printer), create an account with exchange license to use for sending.


4. Load Cloud Shell from top of the Azure Portal. Connect to Exchange:


5. Create an EOL Authentication Policy:

New-AuthenticationPolicy -Name “Allow Basic Auth SMTP” -AllowBasicAuthSmtp

6. Assign the policy to the user:

Get-User user@domain.com | Set-User -AuthenticationPolicy “Allow Basic Auth SMTP”

7. Optionally, force the policy to apply within 30 minutes:

Set-User user@domain.com -STSRefreshTokensValidFrom $([System.DateTime]::UtcNow)

Now your users and devices will be able to connect without MFA requirement from trusted offices, and you can set up Scan to Email functions to use the account you created.

Righto – time have a cup of tea and reward yourself for not chopping off a leg to fix an itch! 🤣🤣


Is your Hybrid Exchange Server SAFE or unknowingly EXPOSED?

Hello and happy 2022! 🥳 I hope the year has started off as well as it possibly can for you (aside from the holiday ending a bit too early of course!)… 🍺😭 …right back to work!

Now, is your Exchange environment safe!!?? Really? Is it?

I love this utility called ‘SMTP Diag Tool’. It’s great for seeing whether you can connect directly to port 25 of an Exchange server. In most cases, if you can, you can send unauthenticated email to accepted domains within the organisation, because that’s how email used to work right?

SMTP Diag Tool

Before we had a mail filtering service in front of Exchange, email was sent directly between servers (in fact if Exchange was configured to use SpamHaus and SpamCop lookups, it did a pretty good job itself!). But I digress…

In many cases lately I have found I can connect directly to, and send unchecked email to Exchange servers, most often using ‘mail’ or ‘webmail’ hostnames. This method bypasses MX records, mail filtering services and also tells you probably have port 443 wide open as well. Clients are shocked – bypassing my mail filter – really? Yep! Here are the scenarios:

  1. You have a mail filtering service, but nobody set up the firewall or Exchange rules to restrict inbound email to the services sending IP Addresses (applies to on-premises and 365).
  2. You have a Hybrid Exchange configuration, some or all mailboxes are in the cloud but you still have port 25 and 443 open to your on-premises server.
  3. And a less serious case but a definite gap to plug – you are cloud only or hybrid, but have not configured rules to stop other tenants from emailing you directly (this would need to be a targeted attack, but I’m sure the attackers will automate the creation of that attack in future if not already).

To fix this there are several options; the more configured the better!

  1. The best step to take is restricting incoming traffic at the firewall. If you can’t use ‘Internet databases’ as firewall objects, throw away your firewall and get a suitable FortiGate model.
  2. Use the ‘Microsoft-Azure’ and ‘Microsoft-Outlook’ objects to restrict incoming (and outbound if you can) 25 traffic to Microsoft’s servers (if you are hybrid and using Defender mail filtering) OR by restricting to your mail filtering service’s IP range for port 25 (if you are on-premises, or routing through it).
  3. Use the Exchange Hybrid Agent! Re-run the wizard and choose the Agent option (you may need to install it manually first if running 2010). This allows you to close port 443 inbound entirely, by performing free / busy lookups and mailbox moves through an Azure App Proxy.
  4. For port 443, you can also use an Azure Application Proxy to act as a gateway to your environment if you are not Hybrid and it must be contactable from the internet (IMO Everyone should be using this for internet facing services – then you can close all inbound ports on your firewall and let Microsoft do the work!! (security team = ✅)
  5. If you do have inbound 443, use IIS with the URL Rewrite module to block access to any virtual directories that are not required (you’re not still using ActiveSync I hope!!?).
  6. You can also modify the Default Receive Connector in Exchange only to accept from a filtering service, or 365, but this is harder to maintain and shouldn’t be needed if the firewall is configured correctly.
  7. For the cloud-only and Hybrid scenarios, make sure you have implemented the Exchange rules to ensure only mail using your MX record will be delivered, ensuring it traverses the Mail Filtering protection of Exchange Online: Advanced Office 365 Routing: Locking Down Exchange On-Premises when MX points to Office 365 – Microsoft Tech Community.

I also recommend performing the following tasks to ensure maximum security for your environment, first focusing on Hybrid Exchange seeing as that is the most common scenario.

  • AD Connect – if it’s been around for a while, make sure it is now on a 2016/2019 server with TLS 1.2 enabled. You must have these OS to install the latest version. Also make sure it is configured for Hybrid Exchange while you are there.
  • Hopefully you are on Exchange 2016 (since that is free for Hybrid and 2019 is not), if not plan and get it done ASAP. Don’t use 2 vCPU and 8GB memory for your Exchange Hybrid server, what are we, skinflints? 4 vCPU and 16GB are recommended and should be easily achievable in any environment. If not then I cry for you… 😭😭
  • Use Azure Automation with Server Update Management to automatically patch your on-premises servers, even if you don’t have Azure yet this is worth enabling it for… WSUS – Yuuuuk! 🤮🤮

Until next time – chur chur from Simon!


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!"
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!"
Write-Output "Connect to Azure AD - Success"

# connect EOL
Try {
    Connect-ExchangeOnline -Credential $CredAzure
        Catch {    
            Write-Error "Failed to connect to EOL!"
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!"
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!"
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!"
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!


How to restrict partner access to Azure and 365

Most customers have accepted at least one, if not multiple invitations from Microsoft partners to provide licensing or support services. What they often dont know is that by default this allows the partner to assign full administrative access to any of it’s staff, to perform tasks in the customers 365 / Azure tenant. It’s an ‘all or nothing’ configuration which is, and should be of concern to many customers who read the fine print of the invitation they are accepting.

The recent hack on a large distributor highlights how dangerous leaving this ‘as is’ can be:

Mega-distie SYNNEX attacked and Microsoft cloud accounts it tends tampered • The Register

Microsoft are developing the Lighthouse solution to allow us to use more detailed permissions for support. But it’s not there yet, so I started testing another solution to ths problem.

Turns out you can do most things using B2B guest access and client targeted URLs (appending the clients custom domain to the admin URL) as below:

Helpdesk staff use:
Azure AD – https://aad.portal.azure.com/customer.com
Exchange – https://outlook.office365.com/ecp/@customer.onmicrosoft.com

Admin staff use:
Azure – https://portal.azure.com/customer.com
Exchange – https://outlook.office365.com/ecp/@customer.onmicrosoft.com
SharePoint – https://customer-admin.sharepoint.com
Intune – https://endpoint.microsoft.com/customer.com  
Security Center – https://securitycenter.windows.com/?tid=customer_tenant_id

Unfortunately this doesn’t seem to apply for the 365 Admin Center (please comment if you found a way to do it!), which is where you would want Helpdesk staff to be performing User / Exchange tasks, rather than jumping between Azure AD and Exchange portals. But, at least it works, and achieves the goal for security conscious customers who are hesitant to accept the partner invitation.

Here is the process:

1. Customer accepts MSP invitation

2. Customer removes the admin / helpdesk agent privileges from the partners area of the customers portal (this keeps the association and you can still procure licensing for them, but removes the default partner permissions)

3. Create two or more groups in customers Azure AD; one for your Helpdesk and one for Admins (with assign roles to group enabled)

4. Assign the roles to the Helpdesk group (change to fit your needs or use custom roles):
User Administrator
Exchange Recipient Administrator

5. Assign roles to the Admin group as required (you can use more admin groups to assign roles to different support groups if required):

Intune Administrator
Authentication Administrator
Exchange Administrator
User Administrator
Guest Inviter
Application Administrator
Compliance Administrator
Global Reader
Conditional Access Administrator
Cloud App Security Administrator
License Administrator
Azure AD Joined Device Local Administrator
Groups Administrator
SharePoint Administrator
Privileged Role Administrator
Azure Information Protection Administrator
Security Administrator

6. Assign Azure subscription roles to the Admin groups as required:

7. You can also use these groups to assign permissions to certain Azure objects, using the IAM blade under the resource

8. Use ‘Bulk Invite’ in customers Azure AD users blade to invite all your support staff to the customer tenant as guests

9. Add the invited guest accounts to the groups you created as required

After a support staff member accepts the invitation, they can open the URLs mentioned above using their standard user account to perform tasks in a customer tenant.

Not perfect, but it does work and avoids using generic credentials to perform tasks in a customer tenant.