Powershell – Recursive Group Membership
Well, I am back for yet another Powershell script. This is one that I found pretty useful actually.
As one of the people really pushing automation in my group at work, I was tasked with getting a list of all users in the “Domain Admins” group for all domains in our Active Directory forest.
One of the challenges in doing this is that you may have a bunch of nested groups and we needed to dump users from all nested groups, etc. I do realize that there are probably tools and what-not that would this for me, but what fun is that and why spend the bucks if you can script it out. I like this approach, too, because I can force the output to be whatever I want and in whichever format is best for what I am trying to accomplish.
From my days in college as a computer science kinda guy, I figured we could use recursion to help walk us through all nested groups.
So for those of you unfamilar with this idea of recursion, I will summarize:
It is basically a function that calls itself until a certain condition is met. At this point the function exits. I know all you CS types may take exception to such a simplified definition, so please google for it or hit up Wikipedia for more detailed info on recursion.
How can this possible help us in our quest to enumeration group memberships? Well let’s break it down a little.
- I start with a group I care about. Let’s say it is “Domain Admins” for domain “office1.contoso.com”.
- I have a function (“get-groupmembers”) that I use to enumerate the members of this group and do something with them (output, write to file, etc)
- As I am enumerating them, I find a member that is of type “group” called something like “Corp Admins”
- I now call my function (“get-groupmembers”) with this nested group (“Corp Admins”) to enumerate the members
- As I am enumerating them, I find ANOTHER group called “Help Desk On-Call” inside of “Corp Admins”
- I can now call my funciton (“get-groupmembers”) ANOTHER time and keep going until I only have users and have walked all of the nested groups
I know this may sound a little weird “that I am calling myself” over and over again, but it is actually pretty efficient.
Let’s jump into some code now.
For starters, I need to have some functions that take a Fully Qualified Domain Name (for an Active Directory Domain) and convert it into an LDAP-ish format. For example, I needed “office1.contoso.com” to be transformed into “DC=office1,DC=contoso,DC=com”. I know this isn’t rocket-surgery, but I just threw some stuff together for it.
function Convert-DNStoDN ([string]$DNSName) { # Create an array of each item in the string separated by "." $DNSArray = $DNSName.Split(".") # Let's go through our new array and do something with each item for ($x = 0; $x -lt $DNSArray.Length ; $x++) { #I don't want a comma after my last item, so check to see if I am on my last one and set # $Separator equal to nothing. # Remember that we need to go to Length-1 because arrays are "0 based indexes" if ($x -eq ($DNSArray.Length - 1)){$Separator = ""}else{$Separator =","} [string]$DN += "DC=" + $DNSArray[$x] + $Separator } return $DN }
We will also need to be able to split the FQDN of the DOMAIN out from the DN of a group or user. So, I have something like “CN=Me,OU=User1,DC=office1,DC=contoso,DC=com” and want to get the FQDN of this domain. For this example this would output “office1.contoso.com”.
function Convert-DNtoDNS ([string]$DN) { $DNArray = $DN.Split(",") # Let's go through our new array and do something with each item for ($x = 0; $x -lt $DNArray.Length ; $x++) { #I don't want a period after my last item, so check to see if I am on my last one and set # $Separator equal to nothing. # Remember that we need to go to Length-1 because arrays are "0 based indexes" if ($x -eq ($DNArray.Length - 1)){$Separator = ""}else{$Separator ="."} # Now we have to see if we look like "DC=". If it does, we will # start to construct our DNS name. if ($DNArray[$x].Split("=")[0] -ilike "DC") { # Let's grab the "contoso" side of the "DC=contoso" [string]$DNS += $DNArray[$x].Split("=")[1] + $Separator } } return $DNS }
Now that we have those little “cameo” functions, we will move on to the more of the meat-and-potatoes of the script.
The next function will be to actually enumerate a group in Active Directory without using the Quest Tools for Active Directory (if you don’t have those yet, you need to them).
We are actually going to use some .NET calls to get the directory objects. My colleague Mike Hays actually did a lot of this part of the code.
function get-groupmember([string]$domain, [string]$groupName) { # I have passed in the FQDN and Groupname I am interested in # I just need to convert my FQDN into an LDAP style name using my previous function $DN = convert-DNStoDN($Domain) $domainLDAPUrl = "LDAP://" + $DN # Setup my directory connection using .NET call $ent = [System.DirectoryServices.DirectoryEntry] ( $domainLDAPUrl ) # Define my "searcher" object to query the directory $srch = [System.DirectoryServices.DirectorySearcher] ( $ent ) # Setup my search criteria.. looking for all Groups with CN=GroupName $groupNameFilter = "(&(objectClass=group)(CN=" + $groupName + "))" $srch.Filter = $groupNameFilter # Now go execute my query to and put the results in $coll $coll = [System.DirectoryServices.SearchResultCollection] $srch.FindAll() foreach ($rs in $coll) { # Now get a collection of properties for that object $resultPropColl = [System.DirectoryServices.ResultPropertyCollection] $rs.Properties # Cycle through all group members foreach ($memberColl in $resultPropColl["member"]) { # Build my membership array [array]$gpMemberEntry += [System.DirectoryServices.DirectoryEntry] ( "LDAP://" + $memberColl ) } } # Send back my group members. return $gpMemberEntry }
Okay.. now that we have THAT setup let’s talk about the next bits of code.
This is where we will have our recursive function “Get-AllMembers”. In it, you will a call to itself. One of the biggest concerns is that you can end up in an unending or infinite cycle. I don’t really do any checking in this little scripty-do-dad, so that may be something for later.
function get-allmembers($objectName, $OF, $GN) { # Split out my domain name (should be FQDN) and the group name $domainName = $objectname.split("\")[0] $ObjectName = $objectname.split("\")[1] $members = get-groupmember "$DomainName" "$ObjectName" if ($members -ne $NULL) { foreach ($member in $members) { # Grab the domain DNS name out of the object DN $ObjDomain = convert-DNtoDNS $Member.DistinguishedName if ($member.objectclass -contains "group") { #If my group member is, itself, a group We get to do some recursion $out = $objDomain + "\" + $member.name Write-Host $out # Call myself with the nested group name get-allmembers -ObjectName $out -OF $of -GN $GN } else { # If I get back a user, then see if the user is disabled or not $userAndDomain = $objDomain + "\" + $member.name # The UserAccountControl property contains several "flags" # that we can interrogate. By doing a Binary AND we are seeing if the 2nd flag is set. [bool]$accountIsDisabled = [int]$member.userAccountControl.ToSTring() -band 2 # Setup our output (I am choosing to construct a comma-delimited type of output $OutText = "'" + $GN + "','" + $objDomain + "','" + $Member.Samaccountname + "','" + $Member.displayName + "','" + $member.distinguishedName + "','" + $objectname + "','" + $accountIsDisabled + "'" Out-File -FilePath $OF -inputobject $Outtext -append -Encoding "ASCII" Write-Host $OutText } } } else { Write-Host "No Members or no Group:" $ObjectName "in Domain:" $DomainName -Foreground RED } } ########################### ## Main ########################### $GroupName = "Domain Admins" $Today = Get-Date -format "yyyyMMddhh" $OutputFolder = "C:\Temp\" if ((Test-Path $outputFolder) -eq $False) { New-Item -Path $OutputFolder -Type Directory > $NULL } # Grab my forest info $forest = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest() #Setup my output file $OutHeader = "'Group','UserDomain','SAMAccount','DisplayName','DN','MemberofGroup','IsDisabled'" # Define output file name $of = $OutputFolder + $Today +"_"+ $GroupName + "-AuditReport.txt" Out-File -FilePath $OF -inputobject $OutHeader -Encoding "ASCII" # Cycle through all the child domains in the forest root to query for Group. foreach ($domain in $forest.Domains) { $FullGroupName = $domain.name + "\" + $GroupName get-allmembers -ObjectName $FullGroupName -OF $of -GN $FullGroupName }
Just take all of the script blocks from above and paste them into your script. I am trying to keep these posts a bit shorter, so you may see upcoming posts broken out into parts.
Well, I think I am done here with this one. Please let me know if you have questions, concerns, or comments.
Please keep in mind that this script will attempt to enumerate the Group in ALL child domains in your current AD Forest. If you have a large Forest with lots of child domains…..this could take a while.
I will be happy to help out with requested changes if they seem like they would be beneficial overall, but I am also a STRONG advocate of doing-it-yourself.
Every bit of Powershell and scripting I have learned by grabbing it and going with it.
Anyway, as always…thanks for stopping by and happy scripting!!!
– Mark
Thanks very much – just what I was looking for!
What would be the cleanest way to prevent circular nesting, or maybe just limit the nesting level?
One way would be to keep a list of groups that have previously been traversed and only recurse if the group you come to hasn’t been done yet. One of the tricks with recursion is that each time you execute the function a new “scope” is created. So variables you set (unless it is a global variable) are only valid in the context of that specific function execution.
SO… you either need to keep track with a global variable, or define a new parameter for the function that allows you to pass the current count of nesting in.
Are you running into an issue with circular nesting using the script?
== Mark
@Mark A. Weaver
No there is no issue. I have a fair idea of what’s involved for recursion – I have done something similar in VBS. I keep the nesting level on the stack with a limit of 10 as a safety net. I agree that to handle circular references properly I would need to keep a list of group names (we have cross-domain nesting so I would need to keep the distinguished names). I do not yet have much powershell experience and I was just being lazy, hoping that you might already have a recursive script. Your nice clean script will be more than enough to get me started. It will be the errohandling/retrying and testing that will take the time. So far I have only been experimenting; I am not even allowed to invoke PS1 files and everything has to be pasted into the PS window. Thanks again
== Maria
No there is no issue. I understand what will be needed – I have done something similar in VBS. I agree that to handle circular references properly I would need to keep a list of group names that have already been processed (we have cross-domain nesting so I would need to keep the distinguished names). I do not yet have much powershell experience and I was just being lazy, hoping that you might already have a script that handles circular references. Your nice clean script will be more than enough to get me started. It will be the errohandling/retrying and testing that will take the time. So far I have only been experimenting; I am not even allowed to invoke PS1 files and everything has to be pasted into the PS window. Thanks again
This looks very interesting. I tried the script on an exchange 2007 server. For certain administrative reasons I need to find all users (with their principalnames) which are members in a certain group. But in this group are also groups nested, where other groups may be nested. What I need is the list of users in a flat list.
Your script is a very close to a solution for that problem. But on out exchange server we have mostly no addons running. It is still using powershell 1.0. so your script does not work.
The notation with “&” is not know by out powershell. ASlso the function .split is not known.
Do You have an idea what I could do about that? What addons do you use?
Thx & greetings Felix
Felix,
This script should walk your nested groups and pull out the list of users from all of those sub-groups.
I did a search through my post and I realize that the “special” characters got HTML-ized when I posted.
The “&” should just be an ampersand character….and the “>” is a GreaterThan symbol.
I have corrected it, so I would recopy the script…
I think this is why you may have been getting errors with “.Split”.
The “.Split” that I am doing is a Method of the [String] type. This is part of Poweshell V1.0. On what line are getting this error?
Hope this helps and thanks for pointing out the errors…
— Mark
Hi Mark,
the HTLM-ized notation of “&” and “>” I corrected already. This is working now. But thx for the hint.
The other problem I fixed also: I changed the function definition from
“function Convert-DNtoDNS ($DN)” to “function Convert-DNtoDNS ([string]$DN)”. Even if a lot of people dont like it: Declaration of variables makes code safer.
Now It is working.
Thx for the publishing of this script.
Felix
Mark – i am new to PS and I have a script for the most part is working. it searches an OU and changes the primarygroupID and removes Domain Users from each account. What was happening was i ran into accounts that are new and you can’t change the PGID until there the user is a member of the new group. So what I needed is for the script when it finds a user that is not a part of the group to add the new group then continue with the rest of it.
Here is what I have so far any help would be great:
$users = Get-QADUser -SearchRoot ‘OU=Enabled,OU=Users,OU=Partners,DC=xxx,DC=com’-MemberOf ‘Allpartners’
$un = read-Host “User Name domain\username” # Your domain and username
$pw = read-host “Enter password” -AsSecureString # Your Password
connect-QADService -service ‘xxx.com’ -ConnectionAccount $un -ConnectionPassword $pw
ForEach ($user in $Users) {Where-Object -FilterScript ($_.Memberof -ne ‘Allpartners’)} {
add-QADGroupMember -identity “CN=AllPartners,OU=Groups,OU=Partners,DC=xxx,DC=com” -member $user
Set-QADUser -Identity $user -ObjectAttributes @{primaryGroupID=@(222753)}
Remove-QADGroupMember -Identity ‘Domain Users’ -Member $user
}
Error I am getting:
ERROR MESSAGE======================
Cannot bind parameter ‘FilterScript’. Cannot convert value “True” to type “System.Management.Automation.ScriptBlock”. Error: “Invalid cast from ‘System.Boolean’ to ‘System.Management.Automation.ScriptBlock’.”
At :line:7 char:54
+ ForEach ($user in $Users) {Where-Object -FilterScript <<<< ($_.Memberof -ne 'Allpartners')} {
PSNOOB,
I will try to assist if I can.
First off, I notice the line it is erroring on:
ForEach ($user in $Users) {Where-Object -FilterScript ($_.Memberof -ne ‘Allpartners’)} {
The error that PoSh is generating is actually due to using parentheses for the Where-Object cmdlet. These should be curly braces…
ForEach ($user in $Users) {Where-Object -FilterScript {$_.Memberof -ne ‘Allpartners’}}
That being said, it will most likely error out again because you are using the “default variable” $_ to try to reference the individual user object. “$_” is used when piping cmdlets together to reference the object being piped in.
An example of this would be:
get-childitem “C:\Temp” | Where-object {$_.Name -eq ‘foo.txt}
I believe that Where-Object requires it to be on the right-side of a pipe (“|”).
Since you are already in the ForEach loop, you should just be using the $user variable and testing to see if it is a member of your group.
The “MemberOf” property of a user account is actually an array of items. Because of that you should probably use “-NotContains” in your condition. “Contains” and “NotContains” look through the entire array and tests each entry against your condition. The groups that are put here are referenced by their distinguished name.
When you assign your $users variable, you seem to be pulling in only users that are already members of the “AllPartners” group. Is that correct?
If so, then you will only have users that are ALREADY in that group, not all users in that OU. I have pulled that out of my revision below.
All-in-all very good, especially if you are new to this. I hadn’t seen the assignment of a primary group before, so that may come in useful for me.
Below I have made some modifications to your script that I think will do what you need. I am not in a place that I can test the code right now, so please take a look and let me know how it goes.
I hope my explanations help…but if not, please let me know and I can elaborate more.
Anyway.. Thanks for stopping by and Keep PoSh-ing!!!
– Mark
#——Begin Code ——————–#
#Get all users in the OU below
$users = Get-QADUser -SearchRoot ‘OU=Enabled,OU=Users,OU=Partners,DC=xxx,DC=com’
#Read in the user’s credentials and store password as secure string
$un = read-Host “User Name domain\username” # Your domain and username
$pw = read-host “Enter password” -AsSecureString # Your Password
#Define the group to make my primary group
$group=“CN=AllPartners,OU=Groups,OU=Partners,DC=xxx,DC=com”
#Connect to the domain with supplied credentials
connect-QADService -service ‘xxx.com’ -ConnectionAccount $un -ConnectionPassword $pw
#Look at each user and see if they are already in the group
ForEach ($user in $Users) {
#If they are not already a member, then we need to add them to the group
if ($user.Memberof -NotContains $group)
{
add-QADGroupMember -identity $group -member $user
}
#Set the primary group for the user
Set-QADUser -Identity $user -ObjectAttributes @{primaryGroupID=@(222753)
#Remove the user from “Domain Users” group
Remove-QADGroupMember -Identity ‘Domain Users’ -Member $user
}
@Mark A. Weaver
Missing ‘=’ operator after key in hash literal.
At :line:27 char:22
+ Remove-QADGroupMember <<<< -Identity 'Domain Users' -Member $user
ah?
thanks for your help BTW
@PSNOOB
I will run this against my lab system and see, but I think it may just be a syntax issue with the quotes/apostrophes used.
Try modifying it to be this ( with ” instead of ’ ) :
Remove-QADGroupMember -Identity “Domain Users” -Member $user
I will test and post later tonight.
– Mark
Okay.. it is amazing how one little bracket/brace/parenthesis can wreak such havoc.
The error in the last run you did was from not closing the curly brace at the end.
Set-QADUser -Identity $user -ObjectAttributes @{primaryGroupID=@(222753)}
I have also done some other things in the code below. The Quest tools tend to spit out lots of data when setting objects or connecting to services, etc.
If you want to see those outputs, remove the ” > $null” pieces from those lines. Basically this says to redirect the console output to $null (or nothing). This essentially suppresses the output from those cmdlets.
I then have a few “write-host” lines to echo what is going on.
BTW: Another way to get credentials is with the Get-Credential cmdlet. You could replace these lines:
$un = read-Host “User Name domain\username” # Your domain and username
$pw = read-host “Enter password” -AsSecureString # Your Password
.
.
connect-QADService -service “xxx.com” -ConnectionAccount $un -ConnectionPassword $pw > $null
with just this one:
connect-QADService -service “xxx.com” -Credential (Get-Credential) > $null
Let me know how it goes!
— Mark
#—-Begin Code ————————————#
#Get all users in the OU below
$users = Get-QADUser -SearchRoot “OU=Enabled,OU=Users,OU=Partners,DC=xxx,DC=com”
#Read in the user’s credentials and store password as secure string
$un = read-Host “User Name domain\username” # Your domain and username
$pw = read-host “Enter password” -AsSecureString # Your Password
#Define the group to make my primary group
$group=”CN=AllPartners,OU=Groups,OU=Partners,DC=xxx,DC=com”
#Connect to the domain with supplied credentials
connect-QADService -service “xxx.com” -ConnectionAccount $un -ConnectionPassword $pw > $null
#Look at each user and see if they are already in the group
ForEach ($user in $Users) {
#If they are not already a member, then we need to add them to the group
if ($user.Memberof -NotContains $group)
{
Write-Host Adding $User.samaccountname to $group …
add-QADGroupMember -identity $group -member $user > $null
}
#Set the primary group for the user
Write-host Setting primaryGroup…
Set-QADUser -Identity $user -ObjectAttributes @{primaryGroupID=@(222753)} > $null
#Remove the user from “Domain Users” group
Write-host Removing $user.samaccountname from Domain Users group…
Remove-QADGroupMember -Identity “Domain USers” -Member $user > $null
}
#—-END Code———————————–#
Very handy script, and a useful intro to PowerShell, thank you!
Thanks very much. I hope to be posting more AD-related stuff soon, so stay tuned!
— Mark
There is an error in the script:
…[System.DirectoryServices.PropertyValueCollection] doesn’t contain a method named ‘Split’.
The function Convert-DNtoDNS ($DN) should be function Convert-DNtoDNS ([String]$DN), otherwise PowerShell thinks that the .split on the first line of the function is a method of the System.Dire….bla…bla thingy
Thanks for the correction. I will update this to force typing on this parameter.
– Mark