Processing Large and Embedded Groups in PowerShell

I'm working with a client that has – over an extended period of time – accumulated thousands of security groups, most of which are mail enabled for use by both Exchange and for setting rights on various objects.

Becasue they use embedded groups (that is, placing a group as a member of another group), they have a horrible time keeping straight who is actually a member of those groups.

Recently an unintentional disclosure at the client has caused them to desire to clean this mess up. 🙂

They found that many tools weren't robust enough to deal with their environment. Their groups may contain thousands of members and dozens of embedded groups. And sometimes, the groups are recursive (or nested). That is, GroupA includes GroupB, GroupB includes GroupC, and GroupC includes GroupA. Also, due to historial reasons, many of their users have non-standard primary group memberships and those needed to also be included as part of the evaluation process.

Note: be aware – most tools will only return 1,500 members for a group (1,000 if your FFL is Windows 2000 mixed or Windows 2000 native). This includes most tools from Microsoft (e.g., dsquery and dsgroup). Some of the tools that handle that properly will go into an infinite loop if there are nested groups. Since the primary group is stored differently than other group memberships, most tools simply ignore it (the RID of the group is stored in the primaryGroupId attribute of a user object, instead of using a memberOf backlink or the member attribute forward link from the group itself).

We were unable to find a tool (which doesn't mean one isn't out there!) that handled all of these issues properly.

So, I wrote one. In PowerShell.

Note that performance is not great when you are scanning nested groups. This is because it is necessary to evaluate every member to determine the type of the member – user, group, contact, etc. That adds significant additional processing overhead.

Each individual piece of this script is pretty obvious (except for the "range" processing required for looking at large group memberships). But after putting it all together, it's a thing of magic. 🙂

Enjoy!


###
### Get-GroupMember
###
### This function processes LARGE groups. Most normal utilities are limited
### to returning a maximum of 1,500 members for a group. To get all members
### of a group requires using a "ranged" member attribute. Few programs,
### including many from Microsoft, go to that much trouble. This one does.
###
### Also, retrieving membership from embedded groups, while avoiding the
### problems that can occur with group recursion, is something that many
### programs do not handle properly. This one does.
###
### Also, some programs do not handle empty groups properly (including the 
### example range program on MSDN from Microsoft). This one does.
###
### Also, some programs do not also check for the primaryGroupID membership, 
### and thus cannot return the membership of, for example, 'Domain Users'. 
### This one does.
###
### The ADSpath for each member of the group is written to the pipeline.
###
### Michael B. Smith
### michael at TheEssentialExchange dot come
### May, 2012
###
### Parameters:
###
###	-group 			The short name for the group. This is looked
###				up to find the distinguishedName of the group.
###
###	-ExpandEmbeddedGroups	Whether to recurse and get the membership of
###				groups contained within the parent group. If
###				this option is specified, all embedded groups
###				are scanned (including groups embedded within
###				groups embedded within groups,etc. etc.).
###
###	-Verbose		Display to the host function entry/exit and
###				status information.
###
###	-VeryVerbose		Display to the host the ADSpath of each member
###				of the group (as well as write it to the pipe).
###
###	-Statistics		Display to the host some basic statistics about
###				the query (number of users, number of embedded
###				groups, number of contacts).
###

Param(
	[string]$group	= (throw "group must be specified"),
	[switch]$ExpandEmbeddedGroups,
	[switch]$Statistics,
	[switch]$Verbose,
	[switch]$VeryVerbose
)

### for the Statistics option

$script:groupUsers    = 0
$script:groupGroups   = 0
$script:groupContacts = 0

function msg
{
	if( -not $Verbose )
	{
		return
	}

	$str = ''
	foreach( $arg in $args )
	{
		$str += $arg
	}
	write-host $str
}

function vmsg
{
	if( -not $VeryVerbose )
	{
		return
	}
	msg $args
}

function Get-PrimaryGroupID
{
	Param(
		[string]$indent,
		[string]$ADSpath
	)

	msg "${indent}Get-PrimaryGroupId: enter, ADSpath = $adspath"

	[string]$pgToken = 'primaryGroupToken'

	### format of argument: LDAP://CN=Domain Users,CN=Users,DC=smithcons,DC=local

	$groupDE  = New-Object System.DirectoryServices.DirectoryEntry( $ADSpath )
	$searcher = New-Object System.DirectoryServices.DirectorySearcher( $groupDE )
	$searcher.Filter = "(objectClass=*)"

	$searcher.PropertiesToLoad.Add( $pgToken ) | Out-Null

	$result = $searcher.FindOne()
	if( $result -ne $null )
	{
		if( $result.Properties.Contains( $pgToken ) -eq $true )
		{
			msg "${indent}Get-PrimaryGroupId: exit, token = $($result.Properties.primarygrouptoken)"

			return $result.Properties.primarygrouptoken
		}
	}

	msg "${indent}Get-PrimaryGroupId: exit, token not found"
	return 0
}

function Search-PrimaryGroupID
{
	Param(
		[string]$indent,
		[string]$namingContext,
		[int]$primaryGroup,
		[hashtable]$dictionary
	)

	msg "${indent}Search-PrimaryGroupId: enter, namingcontext = '$namingContext', primaryGroup = $primaryGroup"

	$ldapFilter = "(primaryGroupID=$primaryGroup)"

	$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
	$directorySearcher.PageSize    = 1000
	$directorySearcher.SearchRoot  = ( "LDAP://" + $namingContext )
	$directorySearcher.SearchScope = "subtree"
	$directorySearcher.Filter      = $ldapFilter

	### load the properties we want

	$directorySearcher.PropertiesToLoad.Add( "distinguishedName" ) | Out-Null
	$directorySearcher.PropertiesToLoad.Add( "objectClass" )       | Out-Null

	$results = $directorySearcher.FindAll()
	if( $results -ne $null )
	{
		msg "${indent}Search-PrimaryGroupId: found $($results.Count) results"
		foreach( $result in $results )
		{
			$myadspath   = $result.Path
			$objCount    = $result.Properties.objectclass.count
			$objectClass = $result.Properties.objectclass[ $objCount - 1 ]

			if( $objectClass -eq 'user' )
			{
				if( $dictionary.$myadspath -eq 1 )
				{
					msg "${indent}Search-PrimaryGroupID: continue duplicate user"
					return
				}
				$dictionary.$myadspath = 1
				$script:groupUsers++
				write-output $myadspath
				vmsg "${indent}Search-PrimaryGroupId: $myadspath"
			}
			else
			{
				write-error "Invalid objectclass for primarygroupid: $objectClass"
			}
		}
	}
	else
	{
		msg "${indent}Search-PrimaryGroupID: result from FindAll() was null"
	}

	msg "${indent}Search-PrimaryGroupId: exit"
}

function Search-Group
{
	Param(
		[string]$indent,
		[string]$ADSpath,
		[hashtable]$dictionary
	)

	### based originally on http://msdn.microsoft.com/en-us/library/bb885125.aspx
	### but has bug-fixes and enhancements

	msg "${indent}Search-Group: enter, $ADSpath"

	$groupDE  = New-Object System.DirectoryServices.DirectoryEntry( $ADSpath )
	$searcher = New-Object System.DirectoryServices.DirectorySearcher( $groupDE )
	$searcher.Filter = "(objectClass=*)"

	[bool]$lastLoop = $false
	[bool]$quitLoop = $false

	[int]$step = 999
	[int]$low  = 0
	[int]$high = $step

	do {
		if( $lastLoop -eq $false )
		{
			[string]$member = 'member;range=' + $low.ToString() + '-' + $high.ToString()
		}
		else
		{
			[string]$member = 'member;range=' + $low.ToString() + '-' + '*'
		}
		msg "${indent}Search-Group: member = $member"

		$searcher.PropertiesToLoad.Clear()        | Out-Null
		$searcher.PropertiesToLoad.Add( $member ) | Out-Null

		$result = $searcher.FindOne()
		if( $result -eq $null )
		{
			### not sure what to do here
			msg "${indent}Search-Group: searcher failure"
			break
		}

		if( $result.Properties.Contains( $member ) -eq $true )
		{
			$entries = $result.Properties.$member
			msg "${indent}Search-Group: entries.Count = $($entries.Count)"
			foreach( $entry in $entries )
			{
				if( $ExpandEmbeddedGroups )
				{
					$memberObj   = [ADSI] "LDAP://$entry"
					$objectClass = $memberObj.objectClass.Item( $memberObj.objectClass.Count - 1 )
					$myadspath   = $memberObj.Path
					$memberObj   = $null
				}
				else
				{
					$myadspath   = $entry
					$objectClass = 'user'
				}
				write-output $myadspath ### output to pipeline

				switch( $objectClass )
				{
					'group'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate group"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupGroups++
							vmsg "${indent}Search-Group: group $myadspath"
							Search-Group ( $indent + '  ' ) $myadspath $dictionary
						}
					'contact'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate contact"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupContacts++
							vmsg "${indent}Search-Group: contact $myadspath"
						}
					'user'
						{
							if( $dictionary.$myadspath -eq 1 )
							{
								msg "${indent}Search-Group: continue duplicate user"
								continue
							}
							$dictionary.$myadspath = 1
							$script:groupUsers++
							vmsg "${indent}Search-Group: user $myadspath"
						}
					'foreignSecurityPrincipal'
						{
							### do nothing
						}
					default
						{
							write-error "Search-Group: unhandled objectClass as member of group: $objectClass"
						}
				}
			}

			### could just say: $quitLoop = $lastLoop
			### but it's not a worthwhile optimization
			### (due to a loss of clarity in WHY)

			if( $lastLoop -eq $true )
			{
				msg "${indent}Search-Group: set quitLoop = true"
				$quitLoop = $true
			}
		}
		else
		{
			if( $lastLoop -eq $true )
			{
				msg "${indent}Search-Group: set quitLoop = true"
				$quitLoop = $true
			}
			else
			{
				msg "${indent}Search-Group: set lastLoop = true"
				$lastLoop = $true
			}
		}

		if( $lastLoop -eq $false )
		{
			msg "${indent}Search-Group: old low = $low, old high = $high"
			$low  = $high + 1
			$high = $low  + $step
			msg "${indent}Search-Group: new low = $low, new high = $high"
		}

	} until( $quitLoop -eq $true )

	$object   = $null
	$searcher = $null
	$groupDE  = $null

	$primaryID = Get-PrimaryGroupId $indent $ADSpath
	if( $primaryID -gt 0 )
	{
		Search-PrimaryGroupId $indent $script:defaultNC $primaryId $dictionary
	}

	msg "${indent}Search-Group: exit, $ADSpath"
}

function Search-ADForGroup
{
	Param(
		[string]$indent,
		[string]$group
	)

	msg "${indent}Search-ADForGroup: enter, group = $group"

	### build the LDAP search to find the group distinguishedName from the provided short name

	$rootDSE    = [ADSI]"LDAP://RootDSE"
	$defaultNC  = $rootDSE.defaultNamingContext
	$ldapFilter = "(&(objectCategory=group)(name=$group))"
	$rootDSE    = $null

	$directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
	$directorySearcher.PageSize    = 1000
	$directorySearcher.SearchRoot  = ( "LDAP://" + $defaultNC )
	$directorySearcher.SearchScope = "subtree"
	$directorySearcher.Filter      = $ldapFilter

	### Define the property we want (if we don't specify at least one property,
	### then "all default properties" get loaded - that's slower).

	$directorySearcher.PropertiesToLoad.Add( "distinguishedName"  ) | Out-Null

	$groups = $directorySearcher.FindAll()
	if( $groups -eq $null )
	{
		write-error "Search-ADForGroup: No such group found: $group"
	}
	elseif( $groups.Count -eq 1 )
	{
		$script:defaultNC = $defaultNC
		msg "${indent}Search-ADForGroup: exit, $($groups.Item( 0 ).Path)"

		return $groups.Item( 0 ).Path ### same as ADSpath in VBScript
	}
	else
	{
		write-error "Search-ADForGroup: Multiple groups were found that match: $group"
	}

	msg "${indent}Search-ADForGroup: exit, null"
	return $null
}

	###
	### Main
	###

	if( $VeryVerbose )
	{
		$Verbose = $true
	}

	$result = Search-ADForGroup '' $group
	if( $result -ne $null )
	{
		$dictionary = @{}

		Search-Group '' $result $dictionary

		if( $Statistics )
		{
			write-host " "
			write-host "Users: $($script:groupUsers)"
			write-host "Groups: $($script:groupGroups)"
			write-host "Contacts: $($script:groupContacts)"
		}

		$dictionary = $null
	}

 

Leave a Reply

Your email address will not be published. Required fields are marked *