Recently, a friend of mine asked me about the best way to find loops in a hierarchy, specifically with Active Directory groups that may be nested (either by design or by mistake) thereby causing loops and this recursion based solution was my recommended answer for him.
The Problem:
Here is the example he gave me:
I am looking for a way to detect recursion loops in a specified AD Group. Take this example of groups and members. I started with Group1, and found Group2, with group member Group2. Group2 has a group member Group3. Enumerating Group3 finds Group1, and loop!
Group Name | Member | Member | Member | Member |
Group1 | User1 | User2 | Group2 | |
Group2 | User1 | User2 | User3 | Group3 |
Group3 | User3 | User8 | Group1 |
He also said: Some groups are nested many levels down, and a single top level group can have more than one recursive loop. The script should be able to not only identify Group1>Group3>Group1, but also Group1>Group8>Group5>Group15>Group1.
The Goals:
- Find Loops (nested hierarchies that loop back up to form a closed circuit)
- Find all the loops in a hierarchy given a top level parent group
- Need to also get the flat list of all members and groups involved in the hierarchy
The Simplified Solution:
Simplifying the problem is the first step I took. It can be tedious to create and rearrange various loops in AD for testing quickly. So, I used simple arrays instead. This solution uses recursion.
Assuming the groups are setup like this:
"Group1" = "User1,User2,Group2,Group8"; "Group2" = "User1,User2,User3,Group3" "Group3" = "User3,User8,Group1" "Group4" = "Group8,User1" "Group5" = "User10,Group15" "Group6" = "User10, User11" "Group7" = "User11" "Group8" = "Group5" "Group15" = "Group1"
Starting with Group4 above, if I ran the simplified example (second part of the sample code below), it should return the output here
#---------------------------------------- #Sample code to test the function #---------------------------------------- $startGroup = 'Group4' [System.Collections.Specialized.OrderedDictionary] $tempWorkHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) [System.Collections.Specialized.OrderedDictionary] $groupsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) [System.Collections.Specialized.OrderedDictionary] $loopsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) [System.Collections.Specialized.OrderedDictionary] $membersReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) Get-GroupLoops ` -Identity $startGroup ` -TempWorkHashTable ([ref]$tempWorkHashTable) ` -GroupsReturnHashTable ([ref]$groupsReturnHashTable) ` -LoopsReturnHashTable ([ref]$loopsReturnHashTable) ` -MembersReturnHashTable ([ref]$membersReturnHashTable) ` -Verbose "----Groups-------" $groupsReturnHashTable "----Members-------" $membersReturnHashTable "----Loops-------" $loopsReturnHashTable
Output is below (notice the Groups, Members and Loops output):
----Groups------- Name Value ---- ----- Group4 Group4 Group8 Group8 Group5 Group5 Group15 Group15 Group1 Group1 Group2 Group2 Group3 Group3 ----Members------- User10 User10 User1 User1 User2 User2 User3 User3 User8 User8 ----Loops------- ->Group4->Group8->Group5->Group15->Group1->Group2->Group3->Group1 ->Group4->Group8->Group5->Group15->Group1->Group8
The Code (2 Parts – Active Directory and Simplified Solution):
I have created a GIT gist and referenced the link to embed GitHub code in this blog post. If you do not see code, please try opening this blog post in a browser window.
The first code snippet finds loops in Active Directory and the second example is one you can play with, without the need for Active Directory.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Author: Jana Sattainathan – https://sqljana.wordpress.com – September 1, 2020 | |
#Gets all the loops (ie., at some point members of groups are groups already traversed in hierarchy) | |
function Get-ADGroupLoops | |
{ | |
param ( | |
[Parameter()] | |
[object]$Identity, | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $TempWorkHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $GroupsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $MembersReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $LoopsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
) | |
[bool] $loopBeginning = $false | |
[string] $fullHierarchyString = "" | |
[string] $loopString = "" | |
$adObject = Get-ADObject -Identity $Identity | |
#If this is group and not an individual | |
if ($adObject.ObjectClass -eq 'group') | |
{ | |
Write-Verbose "Working on: $Identity" | |
if (!$GroupsReturnHashTable.Contains($adObject.DistinguishedName)) | |
{ | |
$GroupsReturnHashTable.Add($adObject.DistinguishedName, $adObject) | |
} | |
#Get the members of the group | |
$members = Get-ADGroupMember -Identity $Identity | |
#If this we have not seen this group before | |
if (!$TempWorkHashTable.Contains($adObject.DistinguishedName)) | |
{ | |
$TempWorkHashTable.Add($adObject.DistinguishedName, $adObject) | Out-Null | |
$members | foreach { | |
#This is the recursive call to itself | |
$members = Get-ADGroupLoops ` | |
-Identity (Get-ADObject -Identity $_) ` | |
-TempWorkHashTable ([ref]$TempWorkHashTable) ` | |
-GroupsReturnHashTable ([ref]$GroupsReturnHashTable) ` | |
-MembersReturnHashTable ([ref]$MembersReturnHashTable) ` | |
-LoopsReturnHashTable ([ref]$LoopsReturnHashTable) | |
} | |
} | |
else | |
{ | |
#We have already seen this group before. That is the starting point of the loop | |
# and we have to print and remove all elements in the Ordered dictionary from that element that form the loop | |
[HashTable] $keysToRemove = @{} | |
foreach($key in $TempWorkHashTable.Keys) | |
{ | |
if ($key -eq $adObject.DistinguishedName) | |
{ | |
$loopBeginning = $true | |
} | |
$fullHierarchyString = $fullHierarchyString + "->" + $TempWorkHashTable[$key].Name | |
if ($loopBeginning -eq $true) | |
{ | |
$keysToRemove.Add($key, $key) | |
$loopString = $loopString + "->" + $TempWorkHashTable[$key].Name | |
} | |
} | |
#The full hierarchy and loop | |
$fullHierarchyString = $fullHierarchyString + "->" + $adObject.Name | |
$loopString = $loopString + "->" + $adObject.Name | |
foreach($key in $keysToRemove.Keys) | |
{ | |
$TempWorkHashTable.Remove($key) | |
} | |
#DN,oGroup | |
$TempWorkHashTable.Add($adObject.DistinguishedName, $adObject) | |
Write-Verbose "Hierarchy: $fullHierarchyString" | |
Write-Verbose "Loop: $loopString" | |
$LoopsReturnHashTable.Add($loopString, $fullHierarchyString) | Out-Null | |
$fullHierarchyString = "" | |
$loopString = "" | |
} | |
} | |
else | |
{ | |
## It is not a group..just a regular user | |
if (!$MembersReturnHashTable.Contains($adObject.DistinguishedName)) | |
{ | |
$MembersReturnHashTable.Add($adObject.DistinguishedName, $adObject) | |
} | |
} | |
} | |
#—————————————- | |
#Sample code to test the function above | |
#—————————————- | |
$startGroup = Get-ADGroup 'YOUR-TOP-LEVEL-AD-GROUP_NAME' | |
[System.Collections.Specialized.OrderedDictionary] $tempWorkHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $groupsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $loopsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $membersReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
Get-ADGroupLoops ` | |
-Identity $startGroup ` | |
-TempWorkHashTable ([ref]$tempWorkHashTable) ` | |
-GroupsReturnHashTable ([ref]$groupsReturnHashTable) ` | |
-LoopsReturnHashTable ([ref]$loopsReturnHashTable) ` | |
-MembersReturnHashTable ([ref]$membersReturnHashTable) ` | |
-Verbose | |
"—-Groups——-" | |
$groupsReturnHashTable | |
"—-Members——-" | |
$membersReturnHashTable | |
"—-Loops——-" | |
$loopsReturnHashTable |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Author: Jana Sattainathan – https://sqljana.wordpress.com – September 1, 2020 | |
#Returns all the groups | |
function Get-Group | |
{ | |
#Notice the loops | |
# Group1 > Group3 > Group1 | |
# Group1 > Group8 > Group5 > Group15 > Group1. | |
@{ | |
"Group1" = "User1,User2,Group2,Group8"; | |
"Group2" = "User1,User2,User3,Group3" | |
"Group3" = "User3,User8,Group1" | |
"Group4" = "Group8,User1" | |
"Group5" = "User10,Group15" | |
"Group6" = "User10, User11" | |
"Group7" = "User11" | |
"Group8" = "Group5" | |
"Group15" = "Group1" | |
} | |
} | |
#Returns all the members of a give group as an array | |
function Get-GroupMember | |
{ | |
param ( | |
[Parameter()] | |
[string]$Identity = 'Group1' | |
) | |
$groups = Get-Group | |
if ($groups.ContainsKey($Identity) -eq $true) | |
{ | |
$groups[$Identity].Split(",") | |
} | |
else | |
{ | |
@() | |
} | |
} | |
#Returns true if if parameter is a Group, else false | |
function Test-Group | |
{ | |
param ( | |
[Parameter()] | |
[string]$Identity = 'Group1' | |
) | |
$groups = Get-Group | |
$groups.ContainsKey($Identity) | |
} | |
#Gets all the loops (ie., at some point members of groups are groups already traversed in hierarchy) | |
function Get-GroupLoops | |
{ | |
param ( | |
[Parameter()] | |
[string]$Identity = 'Group1', | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $TempWorkHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $GroupsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $MembersReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary), | |
[Parameter()] | |
[System.Collections.Specialized.OrderedDictionary][ref] $LoopsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
) | |
[bool] $loopBeginning = $false | |
[string] $fullHierarchyString = "" | |
[string] $loopString = "" | |
#If this is group and not an individual | |
if (Test-Group -Identity $Identity) | |
{ | |
Write-Verbose "Working on: $Identity" | |
if (!$GroupsReturnHashTable.Contains($Identity)) | |
{ | |
$GroupsReturnHashTable.Add($Identity, $Identity) | |
} | |
#Get the members of the group | |
$members = Get-GroupMember -Identity $Identity | |
#If this we have not seen this group before | |
if (!$TempWorkHashTable.Contains($Identity)) | |
{ | |
$TempWorkHashTable.Add($Identity, $Identity) | Out-Null | |
$members | foreach { | |
#This is the recursive call to itself | |
$members = Get-GroupLoops ` | |
-Identity $_ ` | |
-TempWorkHashTable ([ref]$TempWorkHashTable) ` | |
-GroupsReturnHashTable ([ref]$GroupsReturnHashTable) ` | |
-MembersReturnHashTable ([ref]$MembersReturnHashTable) ` | |
-LoopsReturnHashTable ([ref]$LoopsReturnHashTable) | |
} | |
} | |
else | |
{ | |
#We have already seen this group before. That is the starting point of the loop | |
# and we have to print and remove all elements in the Ordered dictionary from that element that form the loop | |
[HashTable] $keysToRemove = @{} | |
foreach($key in $TempWorkHashTable.Keys) | |
{ | |
if ($key -eq $Identity) | |
{ | |
$loopBeginning = $true | |
} | |
$fullHierarchyString = $fullHierarchyString + "->" + $key | |
if ($loopBeginning -eq $true) | |
{ | |
$keysToRemove.Add($key, $key) | |
$loopString = $loopString + "->" + $key | |
} | |
} | |
#The full hierarchy and loop | |
$fullHierarchyString = $fullHierarchyString + "->" + $Identity | |
$loopString = $loopString + "->" + $Identity | |
foreach($key in $keysToRemove.Keys) | |
{ | |
$TempWorkHashTable.Remove($key) | |
} | |
#DN,oGroup | |
$TempWorkHashTable.Add($Identity, $Identity) | |
Write-Verbose "Hierarchy: $fullHierarchyString" | |
Write-Verbose "Loop: $loopString" | |
$LoopsReturnHashTable.Add($loopString, $fullHierarchyString) | Out-Null | |
$fullHierarchyString = "" | |
$loopString = "" | |
} | |
} | |
else | |
{ | |
## It is not a group..just a regular user | |
if (!$MembersReturnHashTable.Contains($Identity)) | |
{ | |
$MembersReturnHashTable.Add($Identity, $Identity) | |
} | |
} | |
} | |
#—————————————- | |
#Sample code to test the functions above | |
#—————————————- | |
$startGroup = 'Group1' | |
[System.Collections.Specialized.OrderedDictionary] $tempWorkHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $groupsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $loopsReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
[System.Collections.Specialized.OrderedDictionary] $membersReturnHashTable = (New-Object System.Collections.Specialized.OrderedDictionary) | |
Get-GroupLoops ` | |
-Identity $startGroup ` | |
-TempWorkHashTable ([ref]$tempWorkHashTable) ` | |
-GroupsReturnHashTable ([ref]$groupsReturnHashTable) ` | |
-LoopsReturnHashTable ([ref]$loopsReturnHashTable) ` | |
-MembersReturnHashTable ([ref]$membersReturnHashTable) ` | |
-Verbose | |
"—-Groups——-" | |
$groupsReturnHashTable | |
"—-Members——-" | |
$membersReturnHashTable | |
"—-Loops——-" | |
$loopsReturnHashTable |
The sample usage code is at the bottom of the code too. Just replace the AD group name with the appropriate one and give it a try. The return values are “Loop strings” but since you have the source code change it as you please!
Lessons Learned:
- Keep it simple, then extrapolate/decorate/complicate
- Variables don’t retain their state when recursion winds or unwinds (pass by reference)
- Pass in the “work” and “return” HashTables as parameters so as to not worry about knowing beginning and end of recursion
- Could return a single object as output but multiple “by reference” parameters work well too since they also output!
Hello, I noticed that this script has an infinite loop for the group “Group2”
startGroup = ‘Group2’
As Group8 and Group2 are in same level.