Thursday, May 19, 2011

Exporting the Membership of a Large, Nested, Active Directory Group

One of the most common requests I receive is to enumerate the membership of an Active Directory group and provide some basic information back about the members. Simple enough task if the group is flat and contains a limited number of members. Once you start dealing with nested groups and groups containing more than 1,500 members (1,000 members for an Active Directory 2000 Forest), you need to start planning how you output the information.

With groups containing more than 1,500 members, performing the following will not provide the entire population.
$groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://CN=All Employees,OU=Distribution Lists,DC=ad,DC=mydomain,DC=local")
foreach($member in $groupObject.member) { Write-Output $member }
You will only enumerate the first 1,500 distinguished names of the multivalued attribute 'member' and output them to the console. Big enough to convince you that you retrieved all the members on first glance but on further investigation you will realize you are missing some people. Hopefully, you caught that and not the person that requested the report with the resolved distinguished names. To overcome this limitation, you need to search within the group object itself and return a range of values within the 'member' attribute; looping until you have enumerated each and every distinguished name.

Active Directory groups can contain user objects, computer objects, contact objects and other groups. To obtain the full membership of a Security Group to understand who has rights to an asset or a Distribution List to understand who will receive an e-mail, you must be aware of nested groups. If a group is a member is a member of the initial group, you will need to enumerate that group and any other groups that it may contain and so on. One of the issues that you can encounter with nested groups is the Administrator that nested a group that is member of a group that itself is patriline to that group. This leads to looping in reporting and must be accounted when recursively enumerating the membership. I tackle this problem by storing the nested group distinguished names that were enumerated in an array and at each recursion evaluating if that distinguished name is an element.

The code sample below deals with these two issues related to group enumeration. I have tested this against code against a group that contained 32,000+ member objects with multiple layers of nesting genreating a 2.9 megabyte report. The only big deficit in the code is there is no provision to deal with objects that are of the foreignSecurityPrincipal class. If you have cross-forest membership in groups, you can add that to the if-then statement and add your own formatting function for those objects. A nice feature of this script is that if you need to enumerate a large number of groups that have similar sAMAccountNames you can wild card them in the command line argument, such as "-group *AdSales*" or "-group DL_*". The code in the script is fairly portable. You can recombine these functions to enumerate the groups associated to NTFS security to provide an access report. You just need to update the functions that deal with the formatting of the objects to provide the desired information.
param([string]$domain,[string]$group,[switch]$verbose)
#-------------------------------------------------------------------------------------------------#
Function Find-GroupDistinguishedNamesBysAMAccountName($domain,$sAMAccountName) {
 $groupDistinguishedNames = @()
 $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher
 $directorySearcher.SearchRoot = (New-Object System.DirectoryServices.DirectoryEntry(("LDAP://" + (Get-LocalDomainController $domain) + "/" + (Get-DomainDn $domain))))
 $directorySearcher.Filter = "(&(objectClass=group)(objectCategory=group)(sAMAccountName=$sAMAccountName))"
 $directorySearcher.PropertiesToLoad.Clear()
 $directorySearcher.PropertiesToLoad.Add("distinguishedName") | Out-Null
 $searchResults = $directorySearcher.FindAll()

 if($searchResults -ne $null) {
  foreach($searchResult in $searchResults) {
   if($searchResult.Properties.Contains("distinguishedName")) {
    $groupDistinguishedNames += $searchResult.Properties.Item("distinguishedName")[0]
   }
  }
 } else {
  $groupDistinguishedNames = $null
 }
 $directorySearcher.Dispose()
 return $groupDistinguishedNames
}
#-------------------------------------------------------------------------------------------------#
Function Get-GroupType($groupType) {
 if($groupType -eq -2147483646) {
  $groupType = "Global Security Group"
 } elseif($groupType -eq -2147483644) {
  $groupType = "Domain Local Security Group"
 } elseif($groupType -eq -2147483643) {
  $groupType = "BuiltIn Group"
 } elseif($groupType -eq -2147483640) {
  $groupType = "Universal Security Group"
 } elseif($groupType -eq 2) {
  $groupType = "Global Distribution List"
 } elseif($groupType -eq 4) {
  $groupType = "Local Distribution List"
 } elseif($groupType -eq 8) {
  $groupType = "Universal Distribution List"
 } else {
  $groupType = "Unknown Group Type"
 }
 return $groupType
}
#-------------------------------------------------------------------------------------------------#
Function Get-GroupMembers($distinguishedName,$level,$groupsReported) {
 $reportText = @()
 $groupObject = New-Object System.DirectoryServices.DirectoryEntry("GC://" + ($distinguishedName -replace "/","\/"))
 if($groupObject.groupType[0] -ne -2147483640 -or $groupObject.groupType[0] -ne 8) {
  $groupObject = New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController(Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
 }
 $directorySearcher = New-Object System.DirectoryServices.DirectorySearcher($groupObject)
 $lastQuery = $false
 $quitLoop = $false
 if($groupObject.member.Count -ge 1000) {
  $rangeStep = 1000
 } elseif($groupObject.member.Count -eq 0) {
  $lastQuery = $true
  $quitLoop = $true
 } else {
  $rangeStep = $groupObject.member.Count
 }
 $rangeLow = 0
 $rangeHigh = $rangeLow + ($rangeStep - 1)
 $level = $level + 2
 while(!$quitLoop) {
  if(!$lastQuery) {
   $attributeWithRange = "member;range=$rangeLow-$rangeHigh"
  } else {
   $attributeWithRange = "member;range=$rangeLow-*"  }
  $directorySearcher.PropertiesToLoad.Clear()
  $directorySearcher.PropertiesToLoad.Add($attributeWithRange) | Out-Null
  $searchResult = $directorySearcher.FindOne()
  $directorySearcher.Dispose()
  if($searchResult.Properties.Contains($attributeWithRange)) {
   foreach($member in $searchResult.Properties.Item($attributeWithRange)) {
    $memberObject = Get-ActiveDirectoryObject $member
    if($memberObject.objectClass -eq "group") {
     $reportText += Format-Group $memberObject $level $groupsReported
    } elseif ($memberObject.objectClass -eq "contact") {
     $reportText += Format-Contact $memberObject $level
    } elseif ($memberObject.objectClass -eq "computer") {
     $reportText += Format-Computer $memberObject $level
    } elseif ($memberObject.objectClass -eq "user") {
     $reportText += Format-User $memberObject $level
    } else {
     Write-Warning "NOT SUPPORTED: $member"
    }
   }
   if($lastQuery) { $quitLoop = $true }
  } else {
   $lastQuery = $true
  }
  if(!$lastQuery) {
   $rangeLow = $rangeHigh + 1
   $rangeHigh = $rangeLow + ($rangeStep - 1)
  }
 }
 return $reportText
}
#-------------------------------------------------------------------------------------------------#
Function Format-User($userObject,$level) {
 $reportText = @()
 if($userObject.displayName) {
  $identity = ((" " * $level) + $userObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]")
 } else {
  $identity = ((" " * $level) + $userObject.name.ToString() + " [" + (Get-ObjectNetBiosDomain $userObject.distinguishedName) + "\" + $userObject.sAMAccountName.ToString() + "]")
 }
 if($userObject.mail) {
  $identity = ("$identity <" + $userObject.mail.ToString() + ">")
 }
 if($userObject.userAccountControl[0] -band 0x0002) {
  $identity = "$identity (User Disabled)"
 } else {
  $identity = "$identity (User Enabled)"
 }
 $reportText += $identity
 $description = ((" " * $level) + "  Description: " + $userObject.description.ToString())
 $reportText += $description
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Contact($contactObject,$level) {
 $reportText = @()
 $identity = ((" " * $level) + $contactObject.displayName.ToString() + " [" + (Get-ObjectNetBiosDomain $contactObject.distinguishedName) + "] <" + $contactObject.mail.ToString() + "> (Contact)")
 $description = ((" " * $level) + "  Description: " + $contactObject.description.ToString())
 $reportText += $identity
 $reportText += $description
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Computer($computerObject,$level) {
 $reportText = @()
    $identity = ((" " * $level) + $computerObject.name + " [" + (Get-ObjectNetBiosDomain $computerObject.distinguishedName) + "\" + $computerObject.sAMAccountName.ToString() + "] (Computer)")
 $operatingSystem = ((" " * $level) + "  OS: " + $computerObject.operatingSystem.ToString())
 $reportText += $identity
    if($computerObject.dNSHostName) {
     $fqdn = ((" " * $level) + "  FQDN: " + ($computerObject.dNSHostName.ToString()).ToLower())
  $reportText += $fqdn
  $reportText += $operatingSystem
    } else {
  $reportText += $operatingSystem
    }
 if($verbose) { Write-Host ($reportText | Out-String) }
 return $reportText
}

Function Format-Group($groupObject,$level,$groupsReported) {
 $reportText = @()
 $identity = ((" " * $level) + $groupObject.name.ToString() + " [" + (Get-ObjectNetBIOSDomain $groupObject.distinguishedName) + "\" + $groupObject.sAMAccountName.ToString() + "]")
 if($groupObject.mail) {
  $identity = ("$identity <" + $groupObject.mail.ToString() + ">")
 }
 $identity = ("$identity (" + (Get-GroupType $groupObject.groupType) + ")")
 $reportText += $identity
 if($verbose) { Write-Host ($reportText | Out-String) }
 if($groupsReported -notcontains $groupObject.distinguishedName) {
  $groupsReported += $groupObject.distinguishedName
  $reportText += Get-GroupMembers $groupObject.distinguishedName.ToString() $level $groupsReported
 } else {
  $reportText += ((" " * $level) + "  [previously reported nested group]")
 }
 $reportText += ""
 return $reportText
}
#-------------------------------------------------------------------------------------------------#
Function Get-AllForestDomains {
 $domains = @()
 $forestInfo = [System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest()
 foreach($domain in $forestInfo.Domains) {
  $domains += $domain.name
 }
 return $domains
}

Function Get-DomainDn($domain) {
 return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).defaultNamingContext).ToString()
}

Function Get-ObjectAdDomain($distinguishedName) {
 return ((($distinguishedName -replace "(.*?)DC=(.*)",'$2') -replace "DC=","") -replace ",",".")
}

Function Get-ObjectNetBiosDomain($distinguishedName) {
 return ((Get-ObjectAdDomain $distinguishedName).Split(".")[0]).ToUpper()
}

Function Get-LocalDomainController($domain) {
 return ((New-Object System.DirectoryServices.DirectoryEntry("LDAP://$domain/RootDSE")).dnsHostName).ToString()
}

Function Get-ActiveDirectoryObject($distinguishedName) {
 return New-Object System.DirectoryServices.DirectoryEntry("LDAP://" + (Get-LocalDomainController (Get-ObjectAdDomain $distinguishedName)) + "/" + ($distinguishedName -replace "/","\/"))
}
#-------------------------------------------------------------------------------------------------#
Set-Variable -name reportsDirectory -option Constant -value "$pwd\Reports"
Set-Variable -name forestDomains -option Constant -value @(Get-AllForestDomains)
#-------------------------------------------------------------------------------------------------#
if(!([bool]$domain) -or !([bool]$group)) {
 Write-Host ("  Example: .\Export-GroupMembership.ps1 -domain " + $forestDomains[0] + " -group Administrators -verbose") -foregroundcolor Yellow
 Write-Host ""
}

if(!([bool]$domain)) {
 Write-Host " You are missing the `"-domain`" Switch" -Foregroundcolor Red
 Write-Host ""
 Write-Host " The domain of the group."
 Write-Host "  Valid Domains:"
 foreach($forestDomain in $forestDomains) {
  Write-Host ("   $forestDomain [" + ($forestDomain.Split("."))[0] + "]")
 }
 Write-Host ""
 Write-Host " Please enter the Domain below"
 while(!([bool]$domain)) {
  $domain = Read-Host -prompt "`tDomain"
 }
 Write-Host ""
}

if(!([bool]$group)) {
 Write-Host " You are missing the `"-group`" Switch" -Foregroundcolor Red
 Write-Host ""
 Write-Host " Please enter the group name below"
 while(!([bool]$group)) {
  $group = Read-Host -prompt "`tGroup"
 }
 Write-Host ""
}

$validDomain = $false
foreach($forestDomain in $forestDomains) {
 if($forestDomain -eq $domain) {
  $validDomain = $true
  break
 }
 if((($forestDomain.Split("."))[0]) -eq $domain) {
  $validDomain = $true
  break
 }
}

if($validDomain -eq $false) {
 Write-Host ""
 Write-Host "$domain is not a valid domain in your current forest." -foregroundcolor Red
 Write-Host ""
 exit
}

if(!(Test-Path -path $reportsDirectory)) {
 New-Item -path $reportsDirectory -type Directory | Out-Null
}

$groupDistinguishedNames = Find-GroupDistinguishedNamesBysAMAccountName $domain $group

if($groupDistinguishedNames -eq $null) {
 Write-Host "Unable to locate $domain\$group. Exiting..." -foregroundColor Red
 exit
}

foreach($groupDistinguishedName in $groupDistinguishedNames) {
 $groupObject = Get-ActiveDirectoryObject $groupDistinguishedName
 $groupName = $groupObject.name.ToString()
 $groupsAMAccountName = $groupObject.sAMAccountName.ToString()
 $groupDomain = Get-ObjectAdDomain $groupDistinguishedName
 $groupNetBiosDomain = Get-ObjectNetBiosDomain $groupDistinguishedName
 $groupType = Get-GroupType $groupObject.groupType
 $outputFilename = ($groupDomain + "-" + $groupsAMAccountName + ".txt")
 $reportText = @()
 $reportText += ("#" + ("-" * 78) + "#")
 $reportText += "  Members of $groupName [$groupNetBiosDomain\$groupsAMAccountName]"
 $reportText += ("  Description: " + $groupObject.description.ToString())
 if($groupObject.mail -and $groupType -match "Distribution") {
  $reportText += "  Distribution List E-Mail Address: " + $groupObject.mail.ToString()
 } elseif($groupObject.mail) {
  $reportText += "  Security Group E-Mail Address: " + $groupObject.mail.ToString()
 }
 $reportText += "  Group Type: $GroupType"
 $reportText += ("  Report generated on " + (Get-Date -format D) + " at " + (Get-Date -format T))
 $reportText += ("#" + ("-" * 78) + "#")
 if($verbose) { Write-Host ($reportText | Out-String) }
 $reportText += Get-GroupMembers $groupDistinguishedName 0 @()
 $reportText += ("#" + ("-" * 78) + "#")
 if($verbose) { Write-Host ("#" + ("-" * 78) + "#") }
 Set-Content -path "$reportsDirectory\$outputFilename" -value ($reportText | Out-String)
}

2 comments:

  1. Hiya,

    Fab post - very useful.

    One query however. How can you included nested Dynamic AD Groups in the above? I've added the below lines but it's not seemingly exporting anything from these particular DL's:
    } elseif ($memberObject.objectClass -eq "msExchDynamicDistributionList") {
    $reportText += Format-Group $memberObject $level $groupsReported

    Cheers

    Steve

    ReplyDelete
  2. Thanks! Dynamic Distribution Lists do not contain members in a member attribute. Essentially, they are LDAP queries that are performed at the time of expansion by Exchange to provide real-time (dynamic) membership. So in order to provide the membership of an object of the class "msExchDynamicDistributionList", you need to execute the LDAP query using the filter storied in the msExchDynamicDLFilter attribute of the object. My code can be updated to satisfy this request. You need to perform the query (global catalog) and then pass the distinguished name of each member to appropriate formatting function. If I have some spare cycles, I will update the script as it is an interesting challenge and I have been negligent in updating this blog.

    ReplyDelete