The other day, one customer asked for a solution to get full user membership in Active Directory for audit purposes. The solution should retrieve not only direct group membership, but indirect (through group nesting) too. Although, the question is plain and simple, solution is very interesting from various perspectives.

At first, let illustrate a sample user and group membership diagram:

Active Directory group membership graph

Quick diagram observation suggests us that we have a directed graph (it is not a tree), where users and groups are vertexes and membership relations are directed edges. Arrows identify relationship direction.

Our graph contains two users, User1 and User2 and eight groups: G1G8. In a given case, User1 is direct member of groups G1, G2 and G3, User2 is direct member of G8 only. Group G1 is member of G4, G2 is direct member of G4 and G5 and so on. For description purposes I labeled all edges. This should be clear.

The algorithm

Our initial algorithm would be as follows:

    1. Initialize array to store current user group membership and assign to $UserGroups.
    2. Get direct groups for user. Assign them to $currentGroups;
    3. Loop over $currentGroups and assign each item to $currentGroup;
    4. Add $currentGroup to the $UserGroups variable;
    5. Retrieve direct groups for $currentGroup and assign them to $currentGroups and repeat steps 3-5.
    6. End loop.

Quick algorithm observation suggests that we will deal with recursive loop.

Potential issues

While it looks pretty legitimate, we may encounter the following issues (and we definitely will with the current diagram):

1) We will have a lot of duplicates. For example, G4 group will be listed twice, because we have two paths from User1 to G4: User1 → e2 → e5 → G4 and User1 → e3 → e6 → G4. The same thing is for G7. We have two paths through: User1 → e1 → e10 –> G7 and User1 → e2 → e4 → e7 → e8 → G7.

Of course, nothing prevents as to use Select-Object –Unique to get unique entries, but this wouldn’t be very efficient. Especially when there are a lot of duplicates.

2) Our logic may never end, because we have one closed walk: when starting from G2 vertex we will return to it through: G2 → e4 → e7 → e8 → e9 → G2. If we do not take additional steps, the code will enter into an infinity loop and eventually will fail with stack overflow exception.

To avoid both potential issues, we have to keep a separate array where we would store visited vertexes (groups). And during each group processing we will check whether the current vertex was already visited and skip if we did. By doing this we will implement a very basic spanning tree algorithm where we convert our graph into tree, so from each Group vertex only single path will exist down to a User vertex. At this point we do not care about which path is shortest, because edges have zero cost. An updated algorithm would look as follows:

  1. Initialize array to store current user group membership and assign to $UserGroups.
  2. Get direct groups for user. Assign them to $currentGroups;
  3. Loop over $currentGroups and assign each item to $currentGroup;
  4. If the $UserGroups contains $currentUser, skip this entry and return to step 3.
  5. Add $currentGroup to the $UserGroups variable;
  6. Retrieve direct groups for $currentGroup and assign them to $currentGroups and repeat steps 3-5.
  7. End loop.

Performance tuning

Step 4 of the updated algorithm suggests multiple searches in $UserGroups array. For small arrays (when user is a member of only few groups) it is acceptable to use linear search that will result in (where n is a total number of user groups) complexity in each iteration and for entire lookup it will take up to . For larger organizations when user is a member of a large number of groups, linear search will quickly become ineffective. To reduce lookup costs, $UserGroups array will be initialized as a hashtable which has complexity for each iteration and will be up to for entire lookup.

Another question is about steps 6 when we will retrieve parent groups for $currentGroup variable. Depending on a group membership complexity, we may encounter multiple identical (redundant) queries to domain controller. For single user lookups it is acceptable to issue separate queries to retrieve group object with memberOf attribute, because they all will be unique (due to step 4). However, when we do such lookup for a large number of users (or even for all users in Active Directory domain), we will get a lot of identical and redundant queries. There are two approaches that may solve this puzzle:

  1. Introduce another array to cache retrieved from Active Directory group objects. And during group object retrieval check if the current group is already cached and use cache information, otherwise issue a query to domain controller. This solution is effective when client has reliable connection to domain controller, but may fail if the connection is poor.
  2. Issue a single request to domain controller and retrieve *all* groups from Active Directory and use it as a local cache. This solution is effective from performance perspective when client is connected to domain controller over poor/unreliable connection, because only single query is issued. Additionally, it is effective when many users are processed. In other cases it may not be effective from memory consumption perspective. Another downside, domain controller will get increased workload to process the query.

For group cache I would recommend to use hashtable as well, because the code will extensively lookup at cache and efficient lookup will positively impact overall script performance. I’m not going to favor any of these solutions, you can select either at your choice. Though, in the code I’m going to use first approach.

Writing solution in PowerShell

In the solution, we will use PowerShell and Active Directory PowerShell module which is shipped with ADDS remote server administration tools (RSAT). Make sure if it is installed on your system. And here is a code with relevant comments:

function Get-UserGroupMembershipRecursive {
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [String[]]$UserName
    )
    begin {
        # introduce two lookup hashtables. First will contain cached AD groups,
        # second will contain user groups. We will reuse it for each user.
        # format: Key = group distinguished name, Value = ADGroup object
        $ADGroupCache = @{}
        $UserGroups = @{}
        # define recursive function to recursively process groups.
        function __findPath ([string]$currentGroup) {
            Write-Verbose "Processing group: $currentGroup"
            # we must do processing only if the group is not already processed.
            # otherwise we will get an infinity loop
            if (!$UserGroups.ContainsKey($currentGroup)) {
                # retrieve group object, either, from cache (if is already cached)
                # or from Active Directory
                $groupObject = if ($ADGroupCache.ContainsKey($currentGroup)) {
                    Write-Verbose "Found group in cache: $currentGroup"
                    $ADGroupCache[$currentGroup]
                } else {
                    Write-Verbose "Group: $currentGroup is not presented in cache. Retrieve and cache."
                    $g = Get-ADGroup -Identity $currentGroup -Property "MemberOf"
                    # immediately add group to local cache:
                    $ADGroupCache.Add($g.DistinguishedName, $g)
                    $g
                }
                # add current group to user groups
                $UserGroups.Add($currentGroup, $groupObject)
                Write-Verbose "Member of: $currentGroup"
                foreach ($p in $groupObject.MemberOf) {
                    __findPath $p
                }
            } else {Write-Verbose "Closed walk or duplicate on '$currentGroup'. Skipping."}
        }
    }
    process {
        foreach ($user in $UserName) {
            Write-Verbose "========== $user =========="
            # clear group membership prior to each user processing
            $UserObject = Get-ADUser -Identity $user -Property "MemberOf"
            $UserObject.MemberOf | ForEach-Object {__findPath $_}
            New-Object psobject -Property @{
                UserName = $UserObject.Name;
                MemberOf = $UserGroups.Values | % {$_}; # groups are added in no particular order
            }
            $UserGroups.Clear()
        }
    }
}

And example output with verbose tracing where we can track code logic:

PS C:\> $report = Get-UserGroupMembershipRecursive user1, user2 -Verbose
VERBOSE: ========== user1 ==========
VERBOSE: Processing group: CN=G3,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G3,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G3,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G6,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G7,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G2,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G5,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Closed walk or duplicate on 'CN=G6,OU=StubOU,DC=sysadmins,DC=lv'. Skipping.
VERBOSE: Processing group: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G4,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Closed walk or duplicate on 'CN=G7,OU=StubOU,DC=sysadmins,DC=lv'. Skipping.
VERBOSE: Processing group: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Closed walk or duplicate on 'CN=G2,OU=StubOU,DC=sysadmins,DC=lv'. Skipping.
VERBOSE: Processing group: CN=G1,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G1,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G1,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Closed walk or duplicate on 'CN=G4,OU=StubOU,DC=sysadmins,DC=lv'. Skipping.
VERBOSE: ========== user2 ==========
VERBOSE: Processing group: CN=G8,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Group: CN=G8,OU=StubOU,DC=sysadmins,DC=lv is not presented in cache. Retrieve and cache.
VERBOSE: Member of: CN=G8,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Found group in cache: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Member of: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Found group in cache: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Member of: CN=G6,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Found group in cache: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Member of: CN=G7,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Found group in cache: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Member of: CN=G2,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Processing group: CN=G5,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Closed walk or duplicate on 'CN=G5,OU=StubOU,DC=sysadmins,DC=lv'. Skipping.
VERBOSE: Processing group: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Found group in cache: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
VERBOSE: Member of: CN=G4,OU=StubOU,DC=sysadmins,DC=lv
PS C:\> $report

MemberOf                                                    UserName
--------                                                    --------
{CN=G5,OU=StubOU,DC=sysadmins,DC=lv, CN=G2,OU=StubOU,DC=... User1
{CN=G8,OU=StubOU,DC=sysadmins,DC=lv, CN=G2,OU=StubOU,DC=... User2


PS C:\> $report[0].MemberOf.name
G5
G2
G7
G3
G4
G1
G6
PS C:\> $report[1].MemberOf.name
G8
G2
G6
G4
G7
G5
PS C:\>

We can confirm that user membership information correctly reflect our diagram. In addition, we see that second user lookup actively uses AD group cache and only one query was issued to domain controller.

HTH


Share this article:

Comments:

Michael Graham

Very well thought out script. I appreciate that you posted it. I had recently created a recursive script myself, and was looking to increase it's performance. My previous script is here: https://github.com/mgraham-cracker/ADSearch if you would like to take a look at some features I built into it.

I went ahead and quickly created a modified version of your script to test a key feature I needed, an inheritance path field. It is often good for me to know how a person is in a group, not just that they are in a group. I put the code below if you were interested.

Thanks again,

--- Code Below ---

function Get-UserGroupMembershipRecursive {
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [String[]]$UserName
    )
    begin {
        # introduce two lookup hashtables. First will contain cached AD groups,
        # second will contain user groups. We will reuse it for each user.
        # format: Key = group distinguished name, Value = ADGroup object
        $ADGroupCache = @{}
        $UserGroups = @{}
        $OutObject = @()
        # define recursive function to recursively process groups.
        function __findPath ([string]$currentGroup, [string]$comment) {
            Write-Verbose "Processing group: $currentGroup"
            # we must do processing only if the group is not already processed.
            # otherwise we will get an infinity loop
            if (!$UserGroups.ContainsKey($currentGroup)) {
                # retrieve group object, either, from cache (if is already cached)
                # or from Active Directory
                $groupObject = if ($ADGroupCache.ContainsKey($currentGroup)) {
                    Write-Verbose "Found group in cache: $currentGroup"
                    $ADGroupCache[$currentGroup].Psobject.Copy()
                } else {
                    Write-Verbose "Group: $currentGroup is not presented in cache. Retrieve and cache."
                    $g = Get-ADGroup -Identity $currentGroup -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
                    # immediately add group to local cache:
                    $ADGroupCache.Add($g.DistinguishedName, $g)
                    $g
                }
                
                $c = $comment + "->" + $groupObject.SamAccountName
                
                $UserGroups.Add($c, $groupObject)
                                
                Write-Verbose "Membership Path:  $c"
                foreach ($p in $groupObject.MemberOf) {
                       __findPath $p $c
                }
            } else {Write-Verbose "Closed walk or duplicate on '$currentGroup'. Skipping."}
        }
    }
    process {
        foreach ($user in $UserName) {
            Write-Verbose "========== $user =========="
            # clear group membership prior to each user processing
            $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
            $UserObject.MemberOf | ForEach-Object {__findPath $_ $UserObject.SamAccountName}
}
            foreach($g in $UserGroups.GetEnumerator())
            {
                $OutObject += [pscustomobject]@{
                    ObjectClass = $g.value.ObjectClass;
                    UserName = $UserObject.SamAccountName;
                    InheritancePath = $g.key;
                    MemberOf = $g.value.SamAccountName;
                    WhenChanged = $g.value.WhenChanged;
                    WhenCreated = $g.value.WhenCreated;
                }
            }
            $OutObject | Sort-Object -Property UserName,InheritancePath,MemberOf | Out-Gridview
            $UserGroups.Clear()
        }
    }

Earl Hyde

I love the work both of you did on this and don't presume to comment on the efficiency. I just added a couple of stylistic modificiations.  GroupScope & GroupCategory were added which are advantageous to have in the output and date outputs were formatted as ragged date columns are hard to read.

function Get-UserGroupMembershipRecursive {
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [String[]]$UserName
    )
    begin {
        # introduce two lookup hashtables. First will contain cached AD groups,
        # second will contain user groups. We will reuse it for each user.
        # format: Key = group distinguished name, Value = ADGroup object
        $ADGroupCache = @{}
        $UserGroups = @{}
        $OutObject = @()
        # define recursive function to recursively process groups.
        function __findPath ([string]$currentGroup, [string]$comment) {
            Write-Verbose "Processing group: $currentGroup"
            # we must do processing only if the group is not already processed.
            # otherwise we will get an infinity loop
            if (!$UserGroups.ContainsKey($currentGroup)) {
                # retrieve group object, either, from cache (if is already cached)
                # or from Active Directory
                $groupObject = if ($ADGroupCache.ContainsKey($currentGroup)) {
                    Write-Verbose "Found group in cache: $currentGroup"
                    $ADGroupCache[$currentGroup].Psobject.Copy()
                } else {
                    Write-Verbose "Group: $currentGroup is not presented in cache. Retrieve and cache."
                    $g = Get-ADGroup -Identity $currentGroup -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof,groupscope,groupcategory
                    # immediately add group to local cache:
                    $ADGroupCache.Add($g.DistinguishedName, $g)
                    $g
                }
                
                $c = $comment + "->" + $groupObject.SamAccountName
                
                $UserGroups.Add($c, $groupObject)
                                
                Write-Verbose "Membership Path:  $c"
                foreach ($p in $groupObject.MemberOf) {
                       __findPath $p $c
                }
            } else { Write-Verbose "Closed walk or duplicate on '$currentGroup'. Skipping." }
        }
    }
    process {
        $enus = 'en-US' -as [Globalization.CultureInfo]
        foreach ($user in $UserName) {
            Write-Verbose "========== $user =========="
            # clear group membership prior to each user processing
            $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
            $UserObject.MemberOf | ForEach-Object {__findPath $_ $UserObject.SamAccountName}
        }
            foreach($g in $UserGroups.GetEnumerator())
            {
                $OutObject += [pscustomobject]@{
                    ObjectClass = $g.value.ObjectClass;
                    UserName = $UserObject.SamAccountName;
                    InheritancePath = $g.key;
                    MemberOf = $g.value.SamAccountName;
                    GroupScope = $g.value.GroupScope;
                    GroupCategory = $g.value.GroupCategory;
                    WhenCreated2 = $g.value.WhenCreated.ToString("MM/dd/yyyy hh:mm tt", $enus);
                    WhenChanged = $g.value.WhenChanged.ToString("MM/dd/yyyy hh:mm tt", $enus);
                }
            }
            $OutObject | Sort-Object -Property UserName,InheritancePath,MemberOf | Out-Gridview
            $UserGroups.Clear()
        }
    }

Tobias

Thank you all for your scripts and findings.

The company I'm working for uses hundreds of startup scripts that have been partially created per user or for smaller user groups. This now needs to be consolidated and brought to a "per team" approach, so I was asked to change the startup script behaviour and put together new scripts that makes sure users only get mappings as they're supposed to.

So for the me question really is:
Is AD user John Doe member (direct or indirect) of the Group TEST_Nested_Group_I_am_well_hidden or not at all.

A simply Yes or No would then do the trick, so thanks @Michael Graham, I have used your code as I'm able to filter out the groups within the GridView display, and if it appears in the list the user somehow belongs to that list or group and I therefore know that the mapping is correct or not. This has been a great help!

Best regards
Tobias

Anonymous

I checked all your script and they looks good but there is one problem I noticed.

It doesn't show the "Domain Users" groups.

Which is default group that every new user have.

udo

Nice script, but because the "Domain Users" Group is skipped the results are not correct.

In my case severak groups are not in the ouput because those groups have just the "Domain Users" group as a member.

 

 

JohnH

Thanks also for the script, it was helpful ... I'm finishing a script that goes the other way Group to users with the pathing (so Get-ADGroupMember -recursive not good enough).

As for the Domain Users issue: the ".MemberOf" method is limited because it does not return the User's Primary Group...if you were to change the Primary Group, that new Primary Group would then be missing methinks.

You can Instead use: Get-ADPrincipalGroupMembership   (BUT  its a little bit slower you'll notice).

Here is the most recent Script in the thread (from Earl Hyde) with the changes (two places)...basically replacing .MemberOf with Get-ADPrincipalGroupMembership cmdlet. I just plopped it in...might want to make it a bit more elegent.

function Get-UserGroupMembershipRecursive {
 [CmdletBinding()]
     param(
         [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
         [String[]]$UserName
     )
     begin {
         # introduce two lookup hashtables. First will contain cached AD groups,
         # second will contain user groups. We will reuse it for each user.
         # format: Key = group distinguished name, Value = ADGroup object
         $ADGroupCache = @{}
         $UserGroups = @{}
         $OutObject = @()
         # define recursive function to recursively process groups.
         function __findPath ([string]$currentGroup, [string]$comment) {
             Write-Verbose "Processing group: $currentGroup"
             # we must do processing only if the group is not already processed.
             # otherwise we will get an infinity loop
             if (!$UserGroups.ContainsKey($currentGroup)) {
                 # retrieve group object, either, from cache (if is already cached)
                 # or from Active Directory
                 $groupObject = if ($ADGroupCache.ContainsKey($currentGroup)) {
                     Write-Verbose "Found group in cache: $currentGroup"
                     $ADGroupCache[$currentGroup].Psobject.Copy()
                 } else {
                     Write-Verbose "Group: $currentGroup is not presented in cache. Retrieve and cache."
                     $g = Get-ADGroup -Identity $currentGroup -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof,groupscope,groupcategory
                     # immediately add group to local cache:
                     $ADGroupCache.Add($g.DistinguishedName, $g)
                     $g
                 }
                
                 $c = $comment + "->" + $groupObject.SamAccountName
                
                 $UserGroups.Add($c, $groupObject)
                                
                 Write-Verbose "Membership Path:  $c"
                 foreach ($p in (Get-ADPrincipalGroupMembership $groupObject.SamAccountName)) {
                        __findPath $p $c
                 }
             } else { Write-Verbose "Closed walk or duplicate on '$currentGroup'. Skipping." }
         }
     }
     process {
         $enus = 'en-US' -as [Globalization.CultureInfo]
         foreach ($user in $UserName) {
             Write-Verbose "========== $user =========="
             # clear group membership prior to each user processing
             $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
             (Get-ADPrincipalGroupMembership $UserObject.SamAccountName) | ForEach-Object {__findPath $_ $UserObject.SamAccountName}
         }
             foreach($g in $UserGroups.GetEnumerator())
             {
                 $OutObject += [pscustomobject]@{
                     ObjectClass = $g.value.ObjectClass;
                     UserName = $UserObject.SamAccountName;
                     InheritancePath = $g.key;
                     MemberOf = $g.value.SamAccountName;
                     GroupScope = $g.value.GroupScope;
                     GroupCategory = $g.value.GroupCategory;
                     WhenCreated2 = $g.value.WhenCreated.ToString("MM/dd/yyyy hh:mm tt", $enus);
                     WhenChanged = $g.value.WhenChanged.ToString("MM/dd/yyyy hh:mm tt", $enus);
                 }
             }
             $OutObject | Sort-Object -Property UserName,InheritancePath,MemberOf | Out-Gridview
             $UserGroups.Clear()
         }
     }

Udo

Hi John,

your script and all above, except Vadims original script will hang in a loop if the AD groups are in a loop as Vadims illustrated in the diagram.

e.g. Membership Path:  User1->G3->G7->G2->G5->G6->G7->G2->G5->G6->G7 and so on and on. This comes from $UserGroups.Add($c, $groupObject). If you change it into $UserGroups.Add($
currentGroup, $groupObject) it will work even if you have a loop in the AD groups. 

 

 

Vadims Podāns

> if you were to change the Primary Group, that new Primary Group would then be missing methinks

it does, however I'm assuming that default primary group value is used. It is extremely rare case when you need to change primary group and in most cases it should be "Domain Users".

Udo

you can also process the primary group however it is called if you change:

            $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
            $UserObject.MemberOf | ForEach-Object {__findPath $_ $UserObject.SamAccountName}

into

            $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof,PrimaryGroup
            $UserObject.MemberOf | ForEach-Object {__findPath $_ $UserObject.SamAccountName}
            $UserObject.PrimaryGroup | ForEach-Object {__findPath $_ $UserObject.SamAccountName}

erlwes

@Tobias

If you only need a certaint level of detail, and the script will be running in end-user context, you can get the groupmemberships from current Windows session like so:

whoami /groups /fo csv | ConvertFrom-Csv

Regards,
Erlend.

Kasper Katzmann

This is so usefull - thumbs up :)

Is there a way to get the results from all users in an OU? 

Something like $Users = @(Get-ADUser -filter * -SearchBase "OU=Users,OU=$UserName,OU=UserAccounts,OU=SITCustomers,DC=PROD,DC=SITAD,DC=DK" | select sAMAccountName)

Can't seem to get it right when I try...

Kasper Katzmann

I figured out how to make it multiuser aware.
By adding user or a customer OU, the scope (User or CU (CustomerUnit)) and how to output (Out-GridView or Csv). 

Example: Get-GroupMemberShips -Customer Contoso -Scope CU -Output Csv
This will create a Csv-file named C:\temp\GroupMemberShips - Contoso - 11-01-2018 14.23.csv

------ SCRIPT BEGIN ------

Function Get-GroupMemberShips
{
<#
  .EXAMPLE
  Get-SITGroupMemberShips -Customer [samAccountName] -Scope [User/Customer OU] -Output [Out-GridView/Csv]

  ObjectClass  |  UserName  |  InheritancePath       |  MemberOf  |  WhenChanged       |  WhenCreated
  group        |  JDOE      |  JDOE->GROUP1->GROUP2  |  GROUP2    |  26-05-2017 18:31  |  21-02-2017 15:13
  
 
#>
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [String[]]$Customer,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 2)]
        [ValidateSet("Csv","Out-GridView")]
        [String[]]$Output,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1)]
        [ValidateSet("User","CU")]
        [String[]]$Scope="User"
    )
$ErrorActionPreference = "SilentlyContinue"

function Get-UserGroupMembershipRecursive {
[CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [String[]]$UserName
    )

$ErrorActionPreference = "SilentlyContinue"

    begin {
        # introduce two lookup hashtables. First will contain cached AD groups,
        # second will contain user groups. We will reuse it for each user.
        # format: Key = group distinguished name, Value = ADGroup object
        $ADGroupCache = @{}
        $UserGroups = @{}
        $OutObject = @()
        # define recursive function to recursively process groups.
        function __findPath ([string]$currentGroup, [string]$comment) {
            Write-Verbose "Processing group: $currentGroup"
            # we must do processing only if the group is not already processed.
            # otherwise we will get an infinity loop
            if (!$UserGroups.ContainsKey($currentGroup)) {
                # retrieve group object, either, from cache (if is already cached)
                # or from Active Directory
                $groupObject = if ($ADGroupCache.ContainsKey($currentGroup)) {
                    Write-Verbose "Found group in cache: $currentGroup"
                    $ADGroupCache[$currentGroup].Psobject.Copy()
                } else {
                    Write-Verbose "Group: $currentGroup is not presented in cache. Retrieve and cache."
                    $g = Get-ADGroup -Identity $currentGroup -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
                    # immediately add group to local cache:
                    $ADGroupCache.Add($g.DistinguishedName, $g)
                    $g
                }
                
                $c = $comment + "->" + $groupObject.SamAccountName
                
                $UserGroups.Add($c, $groupObject)
                                
                Write-Verbose "Membership Path:  $c"
                foreach ($p in $groupObject.MemberOf) {
                       __findPath $p $c
                }
            } else {Write-Verbose "Closed walk or duplicate on '$currentGroup'. Skipping."}
        }
    }
    process {
        foreach ($user in $UserName) {
            Write-Verbose "========== $user =========="
            # clear group membership prior to each user processing
            $UserObject = Get-ADUser -Identity $user -Property objectclass,sid,whenchanged,whencreated,samaccountname,displayname,enabled,distinguishedname,memberof
            $UserObject.MemberOf | ForEach-Object {__findPath $_ $UserObject.SamAccountName}
}
            foreach($g in $UserGroups.GetEnumerator())
            {
                $OutObject += [pscustomobject]@{
                    ObjectClass = $g.value.ObjectClass;
                    UserName = $UserObject.SamAccountName;
                    InheritancePath = $g.key;
                    MemberOf = $g.value.SamAccountName;
                    WhenChanged = $g.value.WhenChanged;
                    WhenCreated = $g.value.WhenCreated;
                }
            }
            $OutObject | Sort-Object -Property UserName,InheritancePath,MemberOf #| Out-Gridview
            $UserGroups.Clear()
        }
    }

If($Scope -eq "CU")
{
    $Users = Get-ADUser -filter * -SearchBase "OU=Users,OU=$Customer,OU=Accounts,OU=OURCustomers,DC=PRODUCTION,DC=OURAD,DC=DK" | select sAMAccountName

    If($Output -eq "Csv")
    {
        $Date = Get-Date -Format d
        $Time = Get-Date -Format t
        $Timestamp = "$Date $Time"
        $Timestamp = $Timestamp -replace(":",".")

        $CsvPath      = "c:\temp\GroupMemberships - $Customer - $Timestamp.csv"
    }

    foreach($user in $users)
    {
        $usr = $user.SamAccountName
        if($Output -eq "Csv")
        {
            Get-UserGroupMembershipRecursive $usr | Export-Csv "c:\temp\GroupMemberships - $Customer - $Timestamp.csv" -Delimiter ";" -NoTypeInformation -Encoding Unicode -Append
        }
        Else
        {
            $thisUser = Get-UserGroupMembershipRecursive $usr
            $allUsers = $allUsers + $thisUser
             
        }
    }

    $allUsers | Out-GridView
}
Else
{
       Get-UserGroupMembershipRecursive $Customer 
}

}

 

------ SCRIPT END ------

Lukas

Built-in solution for presented problem:

Run CMD or Powershell ->

ntdsutil "group member eval" "run <DOMAIN> <samAccountName>"

:)

Best,

Lukas

DarkLite1

Fantastic script guys! Can't improve it unless the following changes I applied:

  • Changed to the .ForEach({}) method instead of piping.
    (Since the newer versions of PowerShell support this method, it's way faster, same for .Where({}))
  • Added the PrimaryGroup as suggested by @Udo
  • Removed some Verbose text to speed things up even more.

Function Get-ADUserGroupMembershipRecursiveHC {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [String[]]$SamAccountName
    )
    
    Begin {
        $ADGroupCache = @{}
        $UserGroups = @{}
        
        Function Get-ADGroupPath ([String]$CurrentGroup) {
            if (-not $UserGroups.ContainsKey($CurrentGroup)) {
                $GroupObject = if ($ADGroupCache.ContainsKey($CurrentGroup)) {
                    $ADGroupCache[$CurrentGroup]
                }
                else {
                    $Group = Get-ADGroup -Identity $CurrentGroup -Property MemberOf
                    $ADGroupCache.Add($Group.DistinguishedName, $Group)
                    $Group
                }
                
                $UserGroups.Add($CurrentGroup, $GroupObject)
                
                @($GroupObject.MemberOf).ForEach({Get-ADGroupPath $_})
            }
            else {
                Write-Verbose "Group '$CurrentGroup' already registered."
            }
        }
    }

    Process {
        Try {
            foreach ($S in $SamAccountName) {
                $User = Get-ADUser -Identity $S -Property MemberOf, PrimaryGroup
                @($User.MemberOf).ForEach({Get-ADGroupPath -CurrentGroup $_})
                @($User.PrimaryGroup).ForEach({Get-ADGroupPath -CurrentGroup $_})

                [PSCustomObject]@{
                    UserName = $User.Name
                    MemberOf = @($UserGroups.Values).ForEach({$_})
                }

                $UserGroups.Clear()
            }
        }
        Catch {
            throw "Failed retrieving group membership recursively for '$SamAccountName': $_"
        }
    }
}

Marcel

Hi.

I've added this line to allow for multidomain support.

                    $Server = ($currentGroup.split(',') | ? { $_.split('=')[0] -eq "DC" } | % { $_.split('=')[1] }) -join '.'
                    $g = Get-ADGroup -Identity $currentGroup -Property "MemberOf" -Server $Server

It takes the DC= parts of the $currentGroup and uses it as server in the Get-ADGroup cmdlet.

Aaron

i know this is late to the party, but users are not the only thing that can be in groups... 

particularly adusers, adcomputers and adserviceaccounts are all useful to enumerate in this way(which can all be contained in an adobject with a filter)

Drew

I agree with aaron, appreciate this user script. is there one similar floating about for computers?

criffo

very nice presentation and good DFS implementation

I encountered the need as well because of RBAC and external trusts.
I developped as well a powershell function but based on a BFS and set parameters to take into account the scope search forest, domain, domain trusts forest trusts or explicit domains. I used the.net classes so no need for the RSAT and activedirectory module. I shared the function on my github for anyone who might have some interest as well
https://github.com/criffo/getADObjectMEmberOfCustom

Stanvy

I’m incredibly surprised to see how such a trivial task leaded to so complicated solutions! In order to get all the groups that object (user, group, contact, computer, ou, foreignSecurityPrincipal - just regardless its’ class) is member of (excluding primary group) the only thing you need is a single LDAP request:
Get-ADObject -LDAPFilter "(member:1.2.840.113556.1.4.1941:=CN=Object Name,OU=OU Name,DC=domain,DC=com)"


Where "CN=Object Name,OU=OU Name,DC=domain,DC=com" is "distinguishedName" of the object of your interest.

criffo

Hi Stanvy,

Excellent I didn't know about this LDAP_MATCHING_RULE_IN_CHAIN

Very practical but still; it is a comparaison until last parent to control if user is a nested member of a group or serie of groups by DN property

Is there a way to get the tree of an adboject memberships; starting form the object and get results from other forests as well ?

Thank you in advance

Actually I did that using the ldapsearcher class but I admit it is quite a long script (avaimabme opn github Criffo : getadobjectmemberof custom)

Actually we needed for users review and memberhips based on a reference user even if we could use the get=adprincipal ... we needed to find any multile memeberships by group depencies in cas of example cross nodes netsing

KryptykHermit

I had to go through this exercise this morning, thinking "someone HAS to have something like this out there already!?".  If anyone is interested, here's my take on the process.  I'm a canonicalname kinda guy, but feel free to adjust as you see fit.  Properties are CASE Sensative!

<code>

function Get-GroupMembersRecursive {

    param(

        [string]$GroupName

    )

    #######################################################

    # FUNCTIONS

    #######################################################

    function ConvertDNtoCN ($DN) {

        foreach ($item in ($DN.replace('\,','~').split(","))) {

            switch ($item.TrimStart().Substring(0,2)) {

                'CN' {$CN = '/' + $item.Replace("CN=","")}

                'OU' {$OU += ,$item.Replace("OU=","");$OU += '/'}

                'DC' {$DC += $item.Replace("DC=","");$DC += '.'}

            }

        }

        $CanonicalName = $DC.Substring(0,$DC.length - 1)

        for ($i = $OU.count;$i -ge 0;$i -- ){$CanonicalName += $OU[$i]}

        if ( $DN.Substring(0,2) -eq 'CN' ) {

            $CanonicalName += $CN.Replace('~','\,')

        }

        Return $CanonicalName

    }

    #######################################################

    # Create an ADSI Searcher

    $searcher = [adsisearcher]''

    # Append a filter for the main group name

    $searcher.Filter = "name=$groupname"

    # Search for the group

    $distinguishedName = $searcher.FindOne().Properties.distinguishedname

 

    # create a filter with the DN of the main group

    [string]$ldapFilter = "(memberOf:1.2.840.113556.1.4.1941:=$($distinguishedName))"

 

    # update the searcher

    $searcher.Filter = $ldapFilter

 

    # Query the users

    $searcher.FindAll().Properties | ForEach-Object {

        [pscustomobject]@{

            Name          = (-join $_.cn)

            Class         = $(if ($_.objectclass -match 'group') { 'Group' } else { 'User' })

            CanonicalName = (ConvertDNtoCN -DN $_.distinguishedname)

            FirstName     = (-join $_.givenname)

            LastName      = (-join $_.sn)

        }

    } | Sort-Object -Property 'Class', 'Name' |

    Format-Table -AutoSize

}

</code>

vit

Get-ADGroup $groupname | Get-ADGroupMember -Recursive

FromMotherRussiaWithLove

AD has the tokenGroups calculated property:

https://docs.microsoft.com/en-us/windows/win32/adschema/a-tokengroups

 

smthg like that:

 

$username = "vasya"

$sids = (Get-ADObject (get-aduser $username).DistinguishedName -Properties tokenGroups).tokenGroups

$domsid = (Get-ADDomain).DomainSID

foreach ($sid in $sids)
{
   if ($sid.AccountDomainSid -notlike $domsid)
   {
         continue
         #here going to global catalog  :)
   }
   get-adgroup $sid.value -Properties name | select name
}

FromMotherRussiaWithLove

* tokenGroups property returns only security group's SIDs, distrib lists are not included

FromMotherRussiaWithLove

Hehey!

 

Just found yet more useful property; msds-memberOfTransitive

Contains DNs of all groups (security and distrib)

Thomas

Hello everyone!

I had my mind boggling on this but I thought it might be easier to post something here.

I love the script but really want the UserGroups to be displayed as names, not as CN. I tried renaming somethings but the script breaks after that.

Anyone an idea?

With kind regards, Thomas

Daniel Verberne

Hi Everyone,

I've really benefited from the scripts and thinking of each of you.

I have found the script posted by Earl Hyde to be particularly useful for fetching the nested group structure for a given USER.

However, I'd love to re-work that basic script so that it accepts a GROUP as input and reads that group's nested structure, i.e. groups it contains, right down to users and their manager's etc.  This is relevant for the Role-Based Access analysis work I'm doing since a corporate restructure.

Any tips on re-working Earl's script to working with a starting group over a user would be much appreciated, thank you!

 


Post your comment:

Please, solve this little equation and enter result below. Captcha