skip to Main Content

Let me preface this by saying I’m not a PowerShell expert by any means. I’ve written the script below, which gives the correct results, but the performance is abysmal. It is taking > 5 minutes to execute, and I know there is a more efficient way to achieve the same result. I’m hoping for some help in refactoring this script to optimize performance as much as possible.

Summary of requirement: Pull a list of users from MS Graph who are in these 4 security groups (they may be a member in more than one group) and add a column to the user details output to indicate a "Yes/No" on whether they are a member of each of the 4 groups respectively.

Desired Output:

DisplayName       : John Smith
Id                : 1234567890
Mail              : [email protected]
UserPrincipalName : [email protected]
JobTitle          : Manager
Group1             : Yes
Group2             : Yes
Group3             : No
Group4             : No
Import-Module Microsoft.Graph.Users.Actions
#Group List
$groups = @{
    GroupIds = @(
        '123456789' #Group 1 
        '987654321' #Group 2
        '154637485' #Group 3
        '856453756' #Group 4
    )
}
Connect-MgGraph -Scopes 'User.Read.All', 'Group.ReadWrite.All'

$tmSku = Get-MgSubscribedSku -All | Where-Object SkuPartNumber -EQ 'SKU1'

$tmUsers = Get-MgUser -Filter "assignedLicenses/any(x:x/skuId eq $($tmsku.SkuId) )" -ConsistencyLevel eventual -CountVariable tmlicensedUserCount -All | Select-Object 'DisplayName', 'Id', 'Mail', 'UserPrincipalName', 'JobTitle' 
$tmUsers | Add-Member -NotePropertyName Group1 -NotePropertyValue No
$tmUsers | Add-Member -NotePropertyName Group2 -NotePropertyValue No
$tmUsers | Add-Member -NotePropertyName Group3 -NotePropertyValue No
$tmUsers | Add-Member -NotePropertyName Group4 -NotePropertyValue No

Write-Host "Found $tmlicensedUserCount users."

foreach ($teammemberuser in $tmUsers) {

    $groupList = Confirm-MgUserMemberGroup -UserId $teammemberuser.Id -BodyParameter $groups 
    if ($groupList -contains '123456789') {
        $teammemberuser.'Group1' = 'Yes'
    }
    if ($groupList -contains '987654321') {
        $teammemberuser.'Group2' = 'Yes'
    }
    if ($groupList -contains '154637485') {
        $teammemberuser.'Group3' = 'Yes'
    }
    if ($groupList -contains '856453756') {
        $teammemberuser.'Group4' = 'Yes'
    }
}

Thanks in advance for any assistance!

2

Answers


  1. There are a few improvements you could make to make your code more efficient.

    The key points are:

    1. Avoid Add-Member at all costs, attaching note properties to an existing object is already expensive and this cmdlet makes it even more expensive
    2. Reduce the amount of Graph calls, currently, you’re querying Graph to get the users that meet your -Filter condition, and then per each user, you’re making a Graph call to check if they’re members of the groups.
      Instead, make a single call to Graph, get the users that meet your -Filter but also get their membership with -ExpandProperty memberOf. You can then use that membership (the .MemberOf property) to do the checks.
    3. Use -Select to reduce the amount of properties you want Graph to return.
    $tmSku = Get-MgSubscribedSku .....
    
    $groups = [ordered]@{
        # key   = The property name in your result object
        # value = The Group Id (the GUID that identifies it)
        Group1 = 'xxxx-xxx-xxx-xxx-xxxxx'
        Group2 = 'xxxx-xxx-xxx-xxx-xxxxx'
        Group3 = 'xxxx-xxx-xxx-xxx-xxxxx'
    }
    
    $getMgUserSplat = @{
        Filter           = "assignedLicenses/any(x: x/skuId eq $($tmsku.SkuId))"
        ConsistencyLevel = 'eventual'
        CountVariable    = 'count'
        Select           = 'DisplayName', 'Id', 'Mail', 'UserPrincipalName', 'JobTitle', 'MemberOf'
        ExpandProperty   = 'memberOf'
        All              = $true
    }
    
    $users = Get-MgUser @getMgUserSplat
    
    $result = foreach ($user in $users) {
        # use a `Hashset<T>` so we have the `.Contains` method from it
        # (fast stuff)
        [System.Collections.Generic.HashSet[guid]] $membership = @($user.MemberOf.Id)
        $outObject = [ordered]@{
            DisplayName       = $user.DisplayName
            Id                = $user.Id
            Mail              = $user.Mail
            UserPrincipalName = $user.UserPrincipalName
            JobTitle          = $user.JobTitle
        }
    
        foreach ($group in $groups.GetEnumerator()) {
            # `.Contains` here outputs a bool (true / false)
            # you can however add a condition here, if true then 'Yes', else 'No'
            $outObject[$group.Key] = $membership.Contains($group.Value)
        }
    
        [pscustomobject] $outObject
    }
    
    $result
    
    Login or Signup to reply.
  2. Adding this as a new answer as I think the previous one is worth keeping for future readers. The issue with the previous answer as we have learnt with OP (see extensive comment section) is that for some undocumented reason and, perhaps a bug, using $expand=memberOf and $expand=transitiveMemberOf (see Properties in user resource type) don’t bring the user’s complete membership so this answer approaches the problem a different way while trying to keep it efficient.

    The main idea, as explained in the previous answer, is to try and reduce the number of Graph calls as much as possible. This is the main reason why your current code is slow. The approach of this answer is to query the groups and get the transitiveMembers that are users (transitiveMembers/microsoft.graph.user) where those users have the target license assigned (assignedLicenses/any(e: e/skuId eq xxx-xx-xxx-xxx)) and, for each user, project their Id ($select=id).

    Then, we follow with the same approach as in your code, get all users having the assigned license but for the comparison we can check if each user exists in the HashShet<T> containing all members Id of each group.

    For reference, the group members query might be doable with Get-MgGroupMember that should make the code less extensive but I personally don’t use the cmdlets in the Graph Module except for Invoke-MgGraphRequest so I honestly have no idea if it’s possible and how to do it with that cmdlet.

    Add-Type -AssemblyName System.Web
    
    # this function handles paging,
    # see for details: https://learn.microsoft.com/en-us/graph/paging
    function Get-GroupMembersId {
        [CmdletBinding()]
        param([string] $uri)
    
        do {
            $response = Invoke-MgGraphRequest GET $uri -Headers @{ ConsistencyLevel = 'eventual' }
            $uri = $response.'@odata.nextLink'
            if ($null -ne $response.value) {
                $response.value.Id
            }
        }
        while ($uri)
    }
    
    # the target Sku here
    $tmSku = Get-MgSubscribedSku .....
    
    # construct the Uri, the idea here will be to get all members of each group
    # in `$groups.Keys` where the members of each group has the assigned license
    # equal to `$tmsku.SkuId` and, for each user, project their Id
    $query = [System.Web.HttpUtility]::ParseQueryString('')
    $query['$count'] = 'true'
    $query['$filter'] = 'assignedLicenses/any(e: e/skuId eq {0})' -f $tmsku.SkuId
    $query['$select'] = 'id'
    $uri = [System.UriBuilder] 'https://graph.microsoft.com'
    $uri.Query = [System.Web.HttpUtility]::UrlDecode($query.ToString())
    
    $groups = [ordered]@{
        # key   = The property name in your result object
        # value = The Group Id (the GUID that identifies it)
        Group1 = 'xxxx-xxx-xxx-xxx-xxxxx'
        Group2 = 'xxxx-xxx-xxx-xxx-xxxxx'
        Group3 = 'xxxx-xxx-xxx-xxx-xxxxx'
    }
    
    # this map will have:
    # key   = The property name in your result object (group1, group2...)
    # value = The Hashset<Guid> containing the user Ids members of each group
    $map = [ordered]@{}
    $groups.GetEnumerator() | ForEach-Object {
        # using `transitiveMembers` for recursive call (use just `members` for direct)
        $uri.Path = 'v1.0/groups/{0}/transitiveMembers/microsoft.graph.user' -f $_.Value
        $map[$_.Key] = [System.Collections.Generic.HashSet[guid]] @(Get-GroupMembersId $uri.Uri.ToString())
    }
    
    # now we continue with the same approach as in the previous answer but here
    # we don't need to `ExpandProperty = 'memberOf'` because we already have all
    # members of each group in `$map`
    $getMgUserSplat = @{
        Filter           = "assignedLicenses/any(x: x/skuId eq $($tmsku.SkuId))"
        Select           = 'DisplayName', 'Id', 'Mail', 'UserPrincipalName', 'JobTitle'
        All              = $true
    }
    
    $users = Get-MgUser @getMgUserSplat
    
    $result = foreach ($user in $users) {
        $outObject = [ordered]@{
            DisplayName       = $user.DisplayName
            Id                = $user.Id
            Mail              = $user.Mail
            UserPrincipalName = $user.UserPrincipalName
            JobTitle          = $user.JobTitle
        }
    
        foreach ($group in $map.GetEnumerator()) {
            # now here remember, `$group.Key` is the group name that
            # will later on be the Property Name of your object
            # and `$group.Value` is a HashSet<Guid> of all members of that group
            # so we can use its `.Contains` method for a super fast search
            $outObject[$group.Key] = $group.Value.Contains($user.Id)
        }
    
        [pscustomobject] $outObject
    }
    
    $result
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search