Author: nrogoff

How to synchronize a large Microsoft (Office) 365 Group membership with Teams using PowerShell

How to synchronize a large Microsoft (Office) 365 Group membership with Teams using PowerShell

Microsoft Teams Logo

Although Microsoft have improved the use of Security and Distribution Group usage in managing large Team memberships, there are still constraints and limitation that may require a scripted solution (see https://docs.microsoft.com/en-us/microsoftteams/best-practices-large-groups).

For Teams limits see https://docs.microsoft.com/en-us/microsoftteams/limits-specifications-teams

You can add a Group (only some types of AD or Microsoft 365 Groups) to a Team, but this will only add the members in a one time action. It will not maintain the membership in sync.

It gets complicated, but you can make a Team from a Microsoft (Office) 365 Group and have it’s membership dynamic, linked to an AD group (see https://docs.microsoft.com/en-us/microsoftteams/dynamic-memberships ), but if you group is large (over 10,000) this can still also run into problems and the group can’t have additional members not part of that group (outsiders!).

I had a client that required a Team to include the membership of an Group that contained over 4,000 members. They also did NOT want members that had made the effort to join the Team, but were not members of the Microsoft 365 group to be removed.

The whole organization was much larger, so much so that they were not able to even create an ‘Org-wide Team’ which allows you to include everyone automatically in a Team up to 5,000 ( see https://docs.microsoft.com/en-us/microsoftteams/create-an-org-wide-team ). We had lots of fun and games with the limits of teams, but came to the following solution.

To get over these hurdles, in the end it was easier to use a PowerShell script and schedule the synching of the members. The following script can be used to add missing members of a Microsoft 365 AD group to a Teams membership. You can also choose whether you want to remove users that are no longer a member of the AD group or just leave them be ūüėČ

Save the script below as Sync-Team-Members-With-AD-Group.ps1 and run the following, replacing the variables as appropriate

Sync-Team-Members-With-AD-Group.ps1
$ADGroupId = "67h3rc03e286-FAKE-ID HERE-8d1c-7b176431"
$TeamDisplayName = "My Big Team"

.\Sync-Team-Members-With-AD-Group.ps1  -ADGroupId $ADGroupId -TeamDisplayName $TeamDisplayName -Credential $Credential -RemoveInvalidTeamMembers $false -Verbose

from a PowerShell terminal. You will need to get the ObjectId of the Azure AD Group and an administrator should be able to help you if you don’t know it.

See the comments in the script for more options, such as limiting the number of users to process etc..

<#PSScriptInfo
.VERSION 1.1
.GUID 21a6ad93-df53-4a1a-82fd-4a902cb57350
.AUTHOR Nicholas Rogoff

.RELEASENOTES
Initial version.
#>
<# 
.SYNOPSIS 
Synchronizes Team membership with an Azure AD Group. 
 
.DESCRIPTION 
Loops through all members of an AD group and adds any missing users to the membership. 
The script then loops through all the existing Team members and removes any that are no longer in the AD Group.

NOTE: This script will NOT remove Owners.

PRE-REQUIREMENT
---------------
Install Teams Module (MS Online)
PS> Install-Module -Name MSOnline

Install Microsoft Teams cmdlets module
PS> Install-Module -Name MicrosoftTeams

.INPUTS
None. You cannot pipe objects to this!

.OUTPUTS
None.

.PARAMETER ADGroupId
This is the Object Id of the Active Directory group you wish to populate the Team with

.PARAMETER TeamDisplayName
The display name of the Team who's membership you wish to alter

.PARAMETER Credential
The credentials (PSCredential object) for an Owner of the Team. Use '$Credential = Get-Credential' to prompt and store the credentials to securely pass

.PARAMETER MaxUsers
Max number of AD Group Users to process. This can be used to test a small batch. Set to 0 to process all members of the AD group

.PARAMETER RemoveInvalidTeamMembers
Default = False. All members of the Team that have are not part of the AD Group will remain members of the Team. E.g. Add-only. 
If you do want sync the membership of the Team exactly, e.g. to remove any Team Members that are not part of the AD group, then set this to True

.NOTES
  Version:        1.1
  Author:         Nicholas Rogoff
  Creation Date:  2020-10-08
  Purpose/Change: Added more detailed help
   
.EXAMPLE 
PS> .\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team" -Credential $Credential
This will add all missing members of the AD Group to the Team

.EXAMPLE
PS> .\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team" -Credential $Credential -MaxUsers 10 -Verbose
This will add all missing members of the first 10 members AD Group to the Team and will output verbose details

.EXAMPLE 
PS> .\Sync-Team-Members-With-AD-Group.ps1 -ADGroupId "4b3f7f45-e8e3-47af-aa82-ecaf41f5e78d" -TeamDisplayName "My Team" -Credential $Credential -RemoveInvalidTeamMembers
This will add all missing members of the AD Group to the Team, and REMOVE any members of the Team that are not in the AD Group (Except for Team Owners)

#>
#---------------------------------------------------------[Script Parameters]------------------------------------------------------
[CmdletBinding()]
Param(
  [Parameter(Mandatory = $true, HelpMessage = "This is the Object Id of the Azure Active Directory group you wish to populate the Team with")]
  [string] $ADGroupId,
  [Parameter(Mandatory = $true, HelpMessage = "The display name of the Team")]
  [string] $TeamDisplayName,
  [Parameter(Mandatory = $true, HelpMessage = "The credentials for an Owner of the Team")]
  [System.Management.Automation.PSCredential] $Credential,
  [Parameter(Mandatory = $false, HelpMessage = "Max number of AD Group Users to process")]
  [int] $MaxUsers = 0,
  [Parameter(Mandatory = $false, HelpMessage = "Default = False. If you do want to remove any Team Members that are not part of the AD group, then set this to True")]
  [bool] $RemoveInvalidTeamMembers = $false
)

#---------------------------------------------------------[Initialisations]--------------------------------------------------------
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Starting synchonisation") -ForegroundColor Blue

# Set Error Action to Silently Continue
$ErrorActionPreference = 'Continue'

# Signin to Office 365 stuff
Connect-MicrosoftTeams -Credential $Credential
Connect-MsolService -Credential $Credential

#----------------------------------------------------------[Declarations]----------------------------------------------------------

if (Get-Module -ListAvailable -Name MSOnline) {
  Write-Host "MSOnline Module exists"
} 
else {
  throw "MSOnline Module does not exist. You can install it by using 'Install-Module -Name MSOnline'"
}

if (Get-Module -ListAvailable -Name MicrosoftTeams) {
  Write-Host "MicrosoftTeams Module exists"
} 
else {
  throw "MicrosoftTeams Module does not exist. You can install it by using 'Install-Module -Name MicrosoftTeams'"
}

#----------------------------------------------------------[Functions]----------------------------------------------------------

function Add-MissingTeamMembers {
  [CmdletBinding()]
  Param(
    [Parameter(Mandatory = $true, HelpMessage = "This is the Team to add the users to")]
    [Microsoft.TeamsCmdlets.PowerShell.Custom.Model.TeamSettings] $team,
    [Parameter(Mandatory = $true, HelpMessage = "This is the AD Group membership from which to add missing members")]
    [Microsoft.Online.Administration.GroupMember[]] $ADGroupMembers
  )
  $TeamMembersAdded = [System.Collections.ArrayList]@()
  #Add missing members
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Checking membership of Team: " + $team.DisplayName + " ( " + $team.GroupId + " ) ") -ForegroundColor Yellow
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Against AD Group: " + $ADGroup.DisplayName + " ( " + $ADGroup.ObjectId + " ) ") -ForegroundColor Yellow
  Write-Host ("--------------------------------------------------------")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Team Membership Total: " + $ExistingTeamMembers.count) -ForegroundColor Yellow
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " AD Group Membership Total: " + $ADGroupMembers.count) -ForegroundColor Yellow
  foreach ($groupMember in $ADGroupMembers) {
    #Check if exists in Teams already
    if ((($ExistingTeamMembers | Select-Object User) -Match $groupMember.EmailAddress).Count -eq 0 ) {
      #Add missing member
      Add-TeamUser -GroupId $team.GroupId -User $groupMember.EmailAddress
      Write-Verbose ("+ Added: " + $groupMember.EmailAddress)
      $TeamMembersAdded.Add($groupMember)
    }
    else {
      Write-Verbose ("| Existed: " + $groupMember.EmailAddress)
    }
  }
  Write-Host ("=====================")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $TeamMembersAdded.count + " new members added") -ForegroundColor Yellow
  Write-Host ("")
}

function Remove-MissingADUsers {
  [CmdletBinding()]
  Param(
    [Parameter(Mandatory = $true, HelpMessage = "This is the list of existing Team users you want to search. The full type should be Microsoft.TeamsCmdlets.PowerShell.Custom.GetTeamUser+GetTeamUserResponse")]
    [object] $ExistingTeamMembers,
    [Parameter(Mandatory = $true, HelpMessage = "This is the AD Group membership from which to check against for invalid team members")]
    [Microsoft.Online.Administration.GroupMember[]] $ADGroupMembers
  )
  $TeamMembersRemoved = [System.Collections.ArrayList]@()
  # Now check for existing Team members that are no longer AD Group members
  foreach ($teamMember in $ExistingTeamMembers) {
    #Check if exists in Teams already
    if (((($ADGroupMembers | Select-Object EmailAddress) -Match $teamMember.User).Count -eq 0) -and ($teamMember.Role -notmatch "owner") ) {
      #Remove from team
      Remove-TeamUser -GroupId $team.GroupId -User $teamMember.User
      $TeamMembersRemoved.Add($teamMember)
      Write-Verbose (" - Removed: " + $teamMember.User)
    }
    else {
      Write-Verbose (" | Not removed: " + $teamMember.User)
    }
  }
  Write-Host ("---------------------")
  Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $TeamMembersRemoved.Count + " Team members removed") -ForegroundColor Yellow
}

#-----------------------------------------------------------[Execution]------------------------------------------------------------

# Get Team
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the Team..." + $TeamDisplayName) -ForegroundColor Blue
$team = Get-Team -DisplayName $TeamDisplayName
# Get AD / Outlook Group Members
$ADGroup = Get-MsolGroup -ObjectId $ADGroupId
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the AD Group..." + $ADGroup.DisplayName) -ForegroundColor Blue
if ($MaxUsers -gt 0) {
  $ADGroupMembers = Get-MsolGroupMember -GroupObjectId $ADGroupId -MaxResults $MaxUsers
}
else {
  $ADGroupMembers = Get-MsolGroupMember -GroupObjectId $ADGroupId -All
}
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $ADGroupMembers.Count + " ...AD Group Members fetched" ) -ForegroundColor Blue
#Get existing Team members
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " Getting the latest Team members..." + $TeamDisplayName) -ForegroundColor Blue
$ExistingTeamMembers = Get-TeamUser -GroupId $team.GroupId
Write-Host ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') + " " + $ADGroupMembers.Count + " ...Team Members fetched") -ForegroundColor Blue
Add-MissingTeamMembers -team $team -ADGroupMembers $ADGroupMembers
if (($RemoveInvalidTeamMembers) -and ($MaxUsers -eq 0)) {
  Remove-MissingADUsers -ExistingTeamMembers $ExistingTeamMembers -ADGroupMembers $ADGroupMembers
}

Write-Host ("=====================")
Write-Host ("****** Completed ******") -ForegroundColor Blue

Ideally, you should have this script run on a scheduled basis to keep things up to date. I may post a blog later on how to create a robust scheduler solution for your whole organisation to implement Team membership synching.

Reboot all the VM’s in a Windows Virtual Desktop Host Pool…safely ;-)

I have found that the session hosts often end up reporting a status of ‘Needs Assistance’. This can be caused by updates having been applied that require a reboot to complete…and other unknown issues. Often a reboot will sort them out. So I developed a simple script to assist.

The following script will allow you to specifiy the only the VM’s in a Host Pool and only those that ‘Need Assistance’ and that have no active sessions on too….or not!

Save the full script to a file called RebootHosts.ps1

<#PSScriptInfo
.VERSION 1.0
.GUID b053571a-b9f4-445d-ac05-45e184cf6f90
.AUTHOR Nicholas Rogoff
.RELEASENOTES

#>
<#
.SYNOPSIS
  Reboots VMs in a HostPool
.DESCRIPTION
  This will iterate through the VMs registered to a host pool and reboot them. 
.NOTES
  Version:        1.0
  Author:         Nicholas Rogoff
  Creation Date:  2020-09-03
  Purpose/Change: Initial script development

.EXAMPLE
  .\RebootHosts.ps1 -HostPoolName "my-host-pool" -HostPoolResourceGroupName "my-host-pool-rg" -OnlyDoIfNeedsAssistance -SkipIfActiveSessions
#>

#---------------------------------------------------------[Script Parameters]------------------------------------------------------
[CmdletBinding()]
Param (
    #Script parameters go here
    [Parameter(mandatory = $true)]
    [string]$HostPoolName,

    [Parameter(mandatory = $true)]
    [string]$HostPoolResourceGroupName,
    
    [Parameter(mandatory = $false)]
    [switch]$SkipIfActiveSessions,

    [Parameter(mandatory = $false)]
    [switch]$OnlyDoIfNeedsAssistance
)

#---------------------------------------------------------[Initialisations]--------------------------------------------------------

#Set Error Action to Silently Continue
$ErrorActionPreference = 'SilentlyContinue'

#----------------------------------------------------------[Declarations]----------------------------------------------------------

#Any Global Declarations go here

#-----------------------------------------------------------[Functions]------------------------------------------------------------


#-----------------------------------------------------------[Execution]------------------------------------------------------------

Write-Output "Starting to Enable Boot Diagnostics for VMs in Host Pool $HostPoolName ..."
if ($OnlyDoIfNeedsAttention) {
    Write-Output "!! Only hosts flagged as 'Needs Assistance' will be rebooted !!"
}
if ($SkipIfActiveSessions) {
    Write-Output "!! Only hosts with zero sessions will be rebooted !!"
}

$rebooted = 0
$skippedSessions = 0
$skippedOK = 0
$shutdown = 0

$sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $HostPoolResourceGroupName -HostPoolName $HostPoolName
foreach ($sh in $sessionHosts) {
    

    # Name is in the format 'host-pool-name/vmname.domainfqdn' so need to split the last part
    $VMName = $sh.Name.Split("/")[1]
    $VMName = $VMName.Split(".")[0]
    
    $Session = $sh.Session
    $Status = $sh.Status
    $UpdateState = $sh.UpdateState
    $UpdateErrorMessage = $sh.UpdateErrorMessage

    Write-output "=== Starting Reboot for VM: $VMName"
    Write-output "Session: $Session"
    Write-output "Status: $Status"
    Write-output "UpdateState: $UpdateState"
    Write-output "UpdateErrorMessage: $UpdateErrorMessage"

    if ($Status -ne "Unavailable") {
        if ($Status -ne "NeedsAssistance" -and $OnlyDoIfNeedsAssistance ) {
            $skippedOK += 1
            Write-output "!! The VM '$VMName' is not in 'Needs Assistance' state, so will NOT be rebooted. !!"       
        }
        elseif ($SkipIfActiveSessions -and $Session -gt 0) {
            $skippeSessions += 1
            Write-output "!! The VM '$VMName' has $Session session(s), so will NOT be rebooted. !!"       
        }
        else {
            $rebooted += 1
            Restart-AzVM -ResourceGroupName $HostPoolResourceGroupName -Name $VMName
            Write-output "=== Reboot initiated for VM: $VMName"       
        }
    }
    else {
        $shutdown += 1
        Write-output "!! The VM '$VMName' must be started in order to reboot it. !!"       
    }

}

Write-Output ""
Write-Output "============== Completed =========================="
Write-Output "Skipped due Not needing attention: $skippedOK"
Write-Output "Skipped due to active sessions: $skippedSessions"
Write-Output "Host not started: $shutdown"
Write-Output "Rebooted: $rebooted"
Write-Output "==================================================="

You will need to log in to Azure and select your subscription in the normal way using:

Login-AzAccount
Select-AzSubscription "my-subscription"

You can then simply run the script as follows:

.\RebootHosts.ps1 -HostPoolName "my-host-pool" -HostPoolResourceGroupName "my-host-pool-rg" -OnlyDoIfNeedsAssistance -SkipIfActiveSessions
Add Git Bash (or other) to Windows Terminal

Add Git Bash (or other) to Windows Terminal

This is my quick, 1 minute, method to add ‘Git for Windows’ bashto Windows Terminal. But you can use the same process for any other command line.

  1. Either use the shortcut CTRL+, or the menu to open the settings.json
  1. This will open the settings.json file in you default editor.
  2. Now generate a new GUID by either
    1. go to https://www.guidgenerator.com/online-guid-generator.aspx or
    2. Enter [guid]::NewGuid() into the PowerShell terminal window
  1. Add the following json to the bottom of the “Profile”:”List” section
,
{
  "guid": "{REPLACE THE GUID HERE WITH YOUR ONE}",
  "hidden": false,
  "name": "Git bash",
  "icon": "C:\\Program Files\\Git\\mingw64\\share\\git\\git-for-windows.ico",
  "commandline": "C:\\Program Files\\Git\\bin\\bash.exe",
  "colorScheme": "One Half Dark",
  "startingDirectory": "%USERPROFILE%"
}
  1. Remember to replace the GUID with the one you created earlier.
  2. Save the file and you should now see the option in the drop down.

If you want this to be your default terminal, then just add the GUID to the “defaultProfile” setting in the json file and save.

I used a simple colour scheme to distinguish this terminal from the other. I also changed the PowerShell one by adding

"colorScheme": "Campbell Powershell"

to that profile to bring back the good ol’ blue background ūüėČ

This is just the tip of the iceberg. You can customise Windows Terminal to your hearts delight.

For more details see the full Windows Terminal docs here https://docs.microsoft.com/en-gb/windows/terminal/

Power Off and On a Heatmiser neoStat v2…that actually works!

Power Off and On a Heatmiser neoStat v2…that actually works!

If, like me, you have been driving yourself crazy trying to follow the instructions in the Heatmiser neoStat V2 manual page 21 and wondering why you can Power the thing off (which is required for a number of actions)…then it’s because it’s wrong. After contacting support, I found the right way ūüôā

Page 21 of the manual, with the wrong instructions!

Here’s how you do it

  1. Use the < > buttons to move to the Power Icon
  2. Press and hold the tick ? for approx. 3 seconds
  3. You will then see a new set of three options. ‘SETUP’, ‘CLOCK’ and Power Icon.
The Power Icon menu
  1. Now use the < > buttons to move to the Power button…again
  2. And now just wait. Do NOT press any buttons.
    After about 10 secs the light will go off…but continue to wait another 10 or so secs till the ‘SETUP’ and ‘CLOCK’ menu items disappear too.
  3. Congratulations…now it’s off and you can perform the configuration steps that now start from this position.

Cloud Resource Naming Convention (Azure)

In any organisation it is important to get a standard naming convention in place for most things, but especially with cloud based resources.

As many types of cloud resources require globally unique names (due to platform DNS resolution), it’s important to have a strategy that will give you a good chance of achieving global uniqueness, but also as helpful as possible to human beings, as well as codefiable in DevOps CD pipelines.

Continue reading “Cloud Resource Naming Convention (Azure)”