Skip to content

Instantly share code, notes, and snippets.

@evetsleep
Created March 1, 2019 12:14
Show Gist options
  • Save evetsleep/b70c85e54e33a892cc38af492094dba3 to your computer and use it in GitHub Desktop.
Save evetsleep/b70c85e54e33a892cc38af492094dba3 to your computer and use it in GitHub Desktop.
function PageMembers {
[CmdletBinding()]Param(
[Parameter(ValueFromPipeline)]
[System.DirectoryServices.SearchResult]$SearchResult
)
process {
<#
When getting a search result back for a group object you will likely get
either a member property OR a member;range=0-1499. If you get the later
then you need to page out the memberships by asking AD for more pages. For
example, if the first page is 0-999, then the next page would be 1000-1999 (if
our increment was 999). After every page you will get a member;range=#-# result,
however when you've reached the last page you'll get a result that looks like this:
mamber;range=#-*
The * means we're done and there are no more pages required.
#>
if ($SearchResult.properties.PropertyNames -match 'member;range=0-\d+') {
Write-Verbose -Message ('Large group: {0}' -f $SearchResult.properties.distinguishedname[0])
# Initialize starting point as 0 and use $done to track if we have more to page out.
$from = 0
$done = $false
$groupDN = $SearchResult.Properties.distinguishedname[0]
$groupEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$GroupDN")
while ($done -eq $false) {
trap {$done = $true; continue}
$to = $from + 999
$memberFilter = 'member;range={0}-{1}' -f $from,$to
Write-Verbose (' {0} Query range: {1}' -f $SearchResult.properties.distinguishedname[0],$memberFilter)
# Connect to and query for the group, but return a paged entry for member.
$ds = New-Object System.DirectoryServices.DirectorySearcher($groupEntry,"(objectClass=*)",$memberFilter,'Base')
$getMore = $ds.FindOne()
# Determine what the member property was that was returned.
$memberProp = $getMore.properties.PropertyNames | Where-Object { $PSItem -match '^member;range=\d+.*' }
Write-Verbose (' {0} Result range: {1}' -f $SearchResult.properties.distinguishedname[0],$memberProp)
# If we're done we need to set the DONE flag
if ($memberProp -match '^member;range=\d+-\*$') { $done = $true }
# Process the paged out members and write them out.
$getMore.properties.$memberProp | ForEach-Object { Write-Output $PSItem }
# Add 1000 to our current starting point.
$from += 1000
}
}
elseif ($SearchResult.properties.PropertyNames -match '^member$') {
# If we got just a member property back then no paging required.
$SearchResult.properties.member
}
else {
Write-Verbose -Message ('No members: {0}' -f $SearchResult.properties.distinguishedname[0])
}
}
}
function Get-ListMember {
<#
.SYNOPSIS
Returns the membership of a group in DN form.
.DESCRIPTION
Returns the membership of a group. If recursion is necessary just pass in the -Recursion parameter. When recursively parsing a groups members, depending on the size of the group, the performance could be quite slow since each member needs to be examined in AD to determine if the member is also a group. If you have a large and slow request you can pre-cache a list of DN's which are users or groups so that the processing is faster. This is in the form of the -Precache and DNCache parameters. If you specify -Precache, then the function will create a hash table of ALL users\groups on its own. If you specify -DNCache, you can build your own cache which must be in the form of:
KEY: DN
Value: USER OR GROUP
.PARAMETER Group
The name of the group to examine. If a regular name is entered we first look it up in AD and get the distinguished name and then examine the membership. If a DN is provided we go straight to connecting to that DN and processing it.
.PARAMETER Recursive
Tells the function to expand all group members which are groups as well.
.PARAMETER Precache
The function will pre-cache the DN:ObjectClass of all users\groups in AD. Depending on your network connection this may take a minute or two and is not always necessary depending on the size of the group(s) you're handling.
.PARAMETER DNCache
You can, if you want, pre-supply a pre-build hash table of DN:objectClass values. This is useful if you're going to be looking up more than one group recursively. An example of pre-building a cache would be:
$DNCache = @{}
Get-ADObject -Server "<GlobalCatalog>:3268" -Filter "objectClass -eq 'user' -or objectClass -eq 'group'" -Properties distinguishedname,objectClass | ForEach-Object {
$DNCache.Add($PSItem.distinguishedname,$PSItem.objectClass)
}
Then you could pass this to Get-ListMember to speed recursion up.
.PARAMETER memberHash
Do not populate this. This is used internally when performing recursive searches\expansions.
.PARAMETER groupHash
Do not populate this. This is used internally when performing recursive searches\expansions.
.EXAMPLE
(Get-ListMember -Group bigGroup).count
5171
Here is a straight group listing (in this case a large group that requires paging).
.EXAMPLE
(Get-ListMember -Group smallGroup).count
10
PS C:\> (Get-ListMember -Group smallGroup -Recursive).count
110
You can see with the first query it looks like there are only 10 members, but that is because some of them are groups. If we recursively go through them we instead can see that there are 110 unique members of the group.
.EXAMPLE
$DNCache = @{}
PS C:\> Get-ADObject -Server "<GlobalCatalog>:3268" -Filter "objectClass -eq 'user' -or objectClass -eq 'group'" -Properties distinguishedname,objectClass | ForEach-Object { $DNCache.Add($PSItem.distinguishedname,$PSItem.objectClass) }
PS C:\> $lotsOfGroups | Foreach-Object { $members = Get-ListMember -Group $PSItem -Recursive -DNCache $DNCache; [PSCustomObject]@{Name=$PSItem;MemberCount=$members.count} }
We have a large list of groups and we want to get a count of all the members. Because some of the groups are nested we need to do a recursive expansion to get all of the members. The DNCache is pre-populated so we can more easily tell, without having to query for each member, what is a user and what is a group. Without the DNCache parameter, each time we see a new member we need to query AD to determine if it is a group (so we can expand that too). This will slow down processing in a very noticeable way if a pre-populated DN cache is not used.
.INPUTS
String
.OUTPUTS
String
#>
[CmdletBinding()]Param(
[Parameter(Mandatory)]
[String]$Group,
[Parameter()]
[Switch]$Recursive,
[Parameter()]
[Switch]$Precache,
[Parameter()]
$DNCache,
[Parameter()]
$memberHash,
[Parameter()]
$groupHash
)
try {
$GC = '{0}:3268' -f (Get-ADDomainController -Discover -Service GlobalCatalog -ErrorAction STOP | Select-Object -ExpandProperty hostname)
}
catch {
Write-Error -ErrorAction STOP -Message ('Failed to discover a global catalog: {0}' -f $PSItem.exception.message)
}
# Parse out an full DN for a group to be processed.
if ($Group -match '^CN=.*') {
$GroupDN = $Group
}
else {
try {
$GroupDN = Get-ADGroup -Filter "name -eq '$Group'" -Server $GC -ErrorAction STOP | Select-Object -ExpandProperty distinguishedname
if ( ($GroupDN | Measure-Object).Count -gt 1 ) {
Write-Error -ErrorAction STOP -Message ('Too many matches found for {0}: {1}. Please specify a DN instead.' -f $Group,$GroupDN.Count)
}
}
catch {
Write-Error -ErrorAction STOP -Message $PSItem.exception.message
}
}
# Used to keep track of the members we've already seen. If we see more that one we ignore all those that follow.
if (-not $memberHash) {
$memberHash = @{}
}
# Used to keep track of groups we've already processed. This prevents us from having a nested loop that lasts forever.
if (-not $groupHash) {
$groupHash = @{}
}
# Give us the ability to pre-load all user\group DN's to speed things along. For large processes this will go a long way
# to improving speed.
if ($Precache -and -not $DNCache) {
$DNCache = @{}
Get-ADObject -Server $GC -Filter "objectClass -eq 'user' -or objectClass -eq 'group'" -Properties distinguishedname,objectClass | ForEach-Object {
$DNCache.Add($PSItem.distinguishedname,$PSItem.objectClass)
}
Write-Verbose ('DN Cache loaded: {0}' -f $DNCache.Keys.Count)
}
# Start with our initial group and load it in.
Write-Verbose ('Processing: {0}' -f $GroupDN)
if ($groupHash.ContainsKey($GroupDN) -eq $false ) {
$groupHash.Add($groupDN,0)
}
# Create a DS object and query for members.
$groupEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$GroupDN")
$groupSearcher = New-Object System.DirectoryServices.DirectorySearcher($groupEntry,"(objectClass=*)",@('distinguishedname','member'),'Base')
$groupObject = $groupSearcher.FindOne()
# If we're going to be recursive we need to do special things, otherwise we just page out the memberships.
if ($Recursive) {
$groupMemberList = New-Object System.Collections.Generic.List[String]
# Get the current membership (one level, no recursion) of the group.
$groupObject | PageMembers | ForEach-Object { $groupMemberList.Add($PSItem) }
foreach ($member in $groupMemberList) {
# If we've already seen either the user or group we don't need to process them again.
if ($memberHash.ContainsKey($member) -eq $true -or $groupHash.ContainsKey($member)) {continue}
# If we are using a cache of DN's we will want to check the cache before going to AD for
# looking up an object to determine its type.
try {
if ($DNCache) {
if ($DNCache.ContainsKey($member) ) {
switch ($DNCache[$member]) {
'group' {
Write-Verbose ('Cache hit(group): {0}' -f $member)
Get-ListMember -Group $member -memberHash $memberHash -groupHash $groupHash -DNCache $DNCache -Recursive
}
'user' {
Write-Verbose ('Cache hit(user): {0}' -f $member)
$memberHash.Add($member,0)
Write-Output $member
}
default {
[ADSI]$child = "LDAP://$member"
Write-Verbose ('Non-Cache hit({0}): {1}' -f $child.SchemaClassName,$member)
if ($child.SchemaClassName -eq 'group') {
Get-ListMember -Group $member -memberHash $memberHash -groupHash $groupHash -DNCache $DNCache -Recursive
}
else {
$memberHash.Add($member,0)
Write-Output $member
}
}
}
}
else {
# If we didn't see the DN in the cache we need to manually inspect it.
[ADSI]$child = "LDAP://$member"
Write-Verbose ('Non-Cache hit({0}): {1}' -f $child.SchemaClassName,$member)
if ($child.SchemaClassName -eq 'group') {
Get-ListMember -Group $member -memberHash $memberHash -groupHash $groupHash -DNCache $DNCache -Recursive
}
else {
$memberHash.Add($member,0)
Write-Output $member
}
}
}
else {
# If we haven't pre-loaded a cache we need to determine if a member is a group or not.
[ADSI]$child = "LDAP://$member"
if ($child.SchemaClassName -eq 'group') {
Get-ListMember -Group $member -memberHash $memberHash -groupHash $groupHash -Recursive
}
else {
$memberHash.Add($member,0)
Write-Output $member
}
}
}
catch {
Write-Error -ErrorAction STOP -Message ('Error processing {0}: {1}' -f $member,$PSItem.exception.message)
}
}
}
else {
# We add the members to the hash just for counting purposes, then spit out the DN.
$groupObject | PageMembers | ForEach-Object { $memberHash.Add($PSItem,0); $PSItem }
}
Write-Verbose ('Groups: {0}; Members: {1}' -f $groupHash.Keys.Count,$memberHash.Keys.Count)
}
Export-ModuleMember -Function Get-ListMember
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment