Active Directory: Documenting your AD Organization with PowerShell
Introduction
Perhaps you have gone to the trouble to assign managers to the users in your Active Directory. Have you considered leveraging that work? It would be nice to generate reports documenting your organization, showing the hierarchy of managers and direct reports. At the very least, you could use the reports to verify what you entered into AD accurately reflects your organization.
This article describes a PowerShell script that can do that.
Download
The script is linked here: Document Active Directory Organization
Assign Managers to Users in ADUC
You can specify a manager for any user on the "Organization" tab of the user properties dialog in the Active Directory Users and Computers MMC (ADUC). This assigns the distinguished name of the manager you select to the manager attribute of the user. The manager attribute is a single-valued DN (distinguished name) attribute. It is linked to the directReports attribute of the corresponding manager. The directReports attribute is a multi-valued DN attribute. The system automatically updates the directReports attribute of the manager for you when you assign the manager to a user. You cannot modify the directReports attribute directly. However, the direct reports can be viewed on the "Organization" tab of ADUC, as seen in the image below:
In this case, Richard Mueller is the manager for Kenneth Mueller. In turn, Kenneth is the manager of Joe M. User, Roger Wilson, and Thomas Jefferson. On this screen you can enter a manager or change the existing one by clicking the "Change" button.
Reveal Hierarchy with a Recursive Function
The script should be able to handle any level of organizational hierarchy you may have. There should be no limit to the number of management levels. This suggests the use of a recursive function. A recursive function is one that actually calls itself as many times as necessary.
Similar functions are used to document nested group membership. You want to be able to handle any level of group nesting, so you use a recursive function. Such a function enumerates the direct members of a specified group, and if any members are found to be groups, the function calls itself to enumerate the members of the nested group.
The danger is that such a function can get caught in an infinite loop. In the case of group membership, it is possible to have circular nested groups. For example, the table below shows user Jim is a member of groupC, and GroupC is itself a member of GroupB. In turn, GroupB is a member of GroupA. GroupC could be Sales, GroupB could be London, and GroupA could be England. Jim is a member of all three groups by virtue of group nesting. However, it is also possible to make GroupB (London) a member of GroupC (Sales). This would be an instance of circular nested groups. The situation is illustrated in the table below, where the memberOf and member attributes of the group objects are documented for this situation.
Object | memberOf | member |
GroupA | GroupB | |
GroupB | GroupA, GroupC | GroupC, Fred |
GroupC | GroupB | Jim, Alice, GroupB |
The circular nesting is highlighted by the red entries in the table. A script that recursively enumerates group memberships would get stuck in an infinite loop, unless the script detects the situation and breaks out of the loop.
Can this happen in the relationship between managers and direct reports? In theory, yes it could, but not to the same extent. By way of example, consider the simple organizational structure below.
Instead of groups A, B, and C, the table documents users Ann, Brandon, and Charles. The other users are not involved in the circular organization. A situation similar to the nested groups would result if user Charles were made the manager of user Brandon. The table below documents the manager and directReports attributes of the users that would result.
Object | manager | directReports |
Ann | Brandon | |
Brandon | Ann, Charles | Charles, Fred |
Charles | Brandon | Jim, Alice, Brandon |
Again, the entries involved in the circular organization are shown in red. But note that the users cannot be configured this way. The manager attribute is single valued. It is not possible for Brandon to have two managers, the way it is possible for GroupB above to be in two groups.
But a circular situation is still possible in this example organization. Charles can be made the manager of Ann. This can be done because Ann would have just one manager. Active Directory allows you to create this circular organization, even though it makes little sense in the real world. Luckily, the script below will ignore this situation.
The recursive function in this script starts out at the top of the organizational hierarchy. The first time the function is called, it searches for all users in the domain that have direct reports, but no manager. There can be more than one such user in the domain, but each would be at the top of a separate organizational structure. The script will document all such organizations. Notice in the made up situation above, where Charles is the manager of Ann, there is no one at the top of the organizational structure. None of the users has direct reports but no manager. The script will ignore the only situation where a circular organization is possible. The example, without the circular situation, is shown in the diagram below:
How the Script Works
We won't discuss the entire script, but important aspects will be explained. The script accepts two optional parameters. One specifies whether users are identified by distinguished names or common names. $Output can be either "DN" (the default) or "Name". The other parameter specifies the format of the output report. $Format can be either "Text" (the default), "HTML", or "CSV" (comma delimited). To keep things simple for this description, assume $Format is "Text". The code for "CSV" and "HTML" will be skipped. Also assume that $Output is "Name", because some extra code is required.
In all of the code that follows, numbers in square brackets to the right, in bold cyan colors, are referenced in the description of the code below. They are not part of the script.
Creating the DirectorySearcher Object
The script first defines functions and processes any optional parameters. Then the script sets up the DirectorySearcher object that will be used to query Active Directory. The code shown below creates the $Searcher object for this purpose [1]. Paging is enabled by assigning a value for PageSize [2] and the attributes to be retrieved are specified [3]. For every user values will be needed for the distinguishedName and directReports attributes. If $Output is equal to "Name", then the Name and sAMAccountName attributes need to be retrieved. The Name attribute is the Relative Distinguished Name (RDN) of the object. The script retrieves sAMAccountName and displays it with the value of Name because Name (the value of the cn attribute of users) may not uniquely identify the object in the domain.
# Check optional parameters indicating output format.
# The default is "Text" format and output "DN" distinguished names.
$Format = "Text"
$Output = "DN"
$Abort = $False
# Code not shown to process optional parameters.
# We show the code for $Output equal to "Name".
$Output = "Name"
# Specify the output file, with the appropriate extension.
Switch ($Format)
{
"Text" {$File = ".\ADOrganization.txt"}
}
# Setup the DirectorySearcher object.
$D = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$Domain = [ADSI]"LDAP://$D"
$Searcher = New-Object System.DirectoryServices.DirectorySearcher [1]
$Searcher.PageSize = 200 [2]
$Searcher.SearchScope = "subtree"
$Searcher.PropertiesToLoad.Add("distinguishedName") > $Null [3]
$Searcher.PropertiesToLoad.Add("directReports") > $Null
If ($Output -eq "Name")
{
$Searcher.PropertiesToLoad.Add("name") > $Null
$Searcher.PropertiesToLoad.Add("sAMAccountName") > $Null
}
$Searcher.SearchRoot = "LDAP://" + $Domain.distinguishedName
# Output header lines.
If ($Format -eq "Text")
{
"Organization: $D" | Out-File -FilePath $File
}
# Retrieve organization hierarchy, starting from the top.
Get-Reports "Top" "" "" "" [4]
# Code not shown to output final tag for HTML format.
# Display the output file, in the application appropriate for the file extension.
Invoke-Expression $File [5]
The Get-Reports Function is Called the First Time
Near the end of the code segment above, the Get-Reports function is called [4]. This is the recursive function discussed earlier. The function accepts four parameters, but all are blank here except the first. The value of the first parameter is "Top", which is the flag for the function to retrieve objects (most likely users) at the top of any organizational structures. Finally in the code above, the Invoke-Expression cmdlet is used to display the output file [5]. This opens the file in the application appropriate for the extension of the file name.
Below is a simplified version of the Get-Reports function. Only the code that executes the first time the function is called is shown. Also, only the code needed for "Text" output is shown.
Function Get-Reports($ReportDN, $ManagerDN, $ManagerName, $Offset) [6]
{
# Recursive function to document the organization.
# The first time this function is called it considers managers at
# the top of the organization hierarchy. These are objects with
# direct reports but no manager.
If ($ReportDN -eq "Top")
{
# Filter on objects with no manager and at least one direct report.
$Filter = "(&(!manager=*)(directReports=*))" [7]
}
Else {# Code not shown that runs when the function is called recursively.}
# Run the query.
$Searcher.Filter = $Filter [8]
$Results = $Searcher.FindAll()
If ($Results.Count -gt 0)
{
# Code not shown to output HTML tabs.
ForEach ($Result In $Results) [9]
{
# Output the object.
$DN = $Result.Properties.Item("distinguishedName") [10]
If ($Output -eq "DN")
{
Switch ($Format)
{
"Text" {$Line = "$Offset$DN"}
}
}
If ($Output -eq "Name")
{
# Retrieve name and sAMAccountName. [11]
$Name = $Result.Properties.Item("name")
$NTName = $Result.Properties.Item("sAMAccountName")
Switch ($Format)
{
"Text" {$Line = "$Offset$Name ($NTName)"} [12]
}
}
$Line | Out-File -FilePath $File -Append [13]
# Retrieve any direct reports for this object.
$Reports = $Result.Properties.Item("directReports") [14]
If ($Reports.Count -gt 0)
{
# Code not shown to output HTML tags.
ForEach ($Report In $Reports)
{
# Recursively call this function for each direct report.
# Increase any indenting by 4 more spaces.
Get-Reports $Report $DN "$Name ($NTName)" "$Offset " [15]
}
# Code not shown to output HTML tags.
}
}
# Code not shown to output HTML tags.
}
}
The four parameters for the function are [6]:
- $ReportDN: The distinguished name of a direct report.
- $ManagerDN: The distinguished name of the manager of the direct report.
- $ManagerName: The common name and sAMAccountName of the manager.
- $Offset: Blank spaces to indent direct reports under their manager, when $Output is "Text".
As noted, when this function is first called, all input parameters are blank except $ReportDN, which has the value "Top". Looking at the code above, note that for this first pass, the LDAP filter to query AD is specified as: "(&(!manager=*)(directReports=*))" [7]. This LDAP filter has two clauses in parentheses, joined by the "&" operator, meaning that the conditions in both clauses must be satisfied for records in AD to be returned by the query. Each clause consists of an attribute name, an operator, and a value. The operator in both of these clauses is the equal operator, "=", and the value in both cases is the wildcard character, "*". The final feature of this filter is the "!" character, which is the "NOT" operator in LDAP syntax. So, the second clause in the filter, "(directReports=*)", means that the directReports attribute must have at least one value. The first clause, "(!manager=*)", means that the manager attribute cannot have any value. It must be missing. The script retrieves all objects that have at least one direct report, but has no manager. There may be more than one such object. Each object found will be at the top of a separate organizational structure.
Notice the constant reference above to objects rather than users. That is because managers and direct reports don't need to be users. The manager attribute is available for AD objects of class user, contact, and computer. The directReports attribute is available for user, contact, computer, group, organizationalUnit, container, and even domain objects. Most likely only user (and perhaps contact and group) objects would be used, but the script allows for any possibilities. That is why the LDAP filters do not include any clauses restricting the class of objects.
In the code above, the filter for objects at the top of any organizational hierarchy is applied to the $Searcher object [8]. The results are then enumerated in a ForEach loop [9]. For each object found the distinguished name is retrieved [10]. If $Output is "Name", the values of the Name and sAMAccountName attributes must be retrieved separately [11]. Note that sAMAccountName could be blank if the object is a contact. Next, the values are added to the string variable $Line [12], which is then appended to the appropriate file [13].
Next, the script retrieves the value of the directReports attribute of the object just documented (the object identified by $ReportDN) [14]. The result can be missing, one DN, or more than one DN value. The values are handled in a ForEach loop, in which the Get-Reports function calls itself recursively [15]. This time all parameters of the function potentially have values. Note that the $Offset parameter has four space characters appended, so that the direct reports will be indented under their manager if the output is "Text".
Get-Reports is Called Recursively
Now look at the GetReport function statements that execute when the function is called recursively for each direct report. This is shown in the simplified code below:
Function Get-Reports($ReportDN, $ManagerDN, $ManagerName, $Offset)
{
# Recursive function to document the organization.
# The first time this function is called it considers managers at
# the top of the organization hierarchy. These are objects with
# direct reports but no manager.
If ($ReportDN -eq "Top") {# Code not shown that runs when function first called.}
Else
{
# The function has been called recursively to deal with a direct report.
# Output the object that reports to the previous manager. [16]
If ($Output -eq "DN")
{
Switch ($Format)
{
"Text" {
# Direct reports are indented beneath their manager.
"$Offset$ReportDN" | Out-File -FilePath $File -Append
# Indent the next level of the hierarchy 4 spaces.
$Offset = "$Offset "
}
}
}
If ($Output -eq "Name")
{
# Escape any forward slash characters with the backslash escape character.
$ReportDN = $ReportDN.Replace("/", "\") [17]
# Use ADSI to bind to the direct report object and retrieve names.
$Object = [ADSI]"LDAP://$ReportDN" [18]
$Name = $Object.name
$NTName = $Object.sAMAccountName
$ReportName = "$Name ($NTName)" [19]
Switch ($Format)
{
"Text" {
# Direct reports are indented beneath their manager.
"$Offset$ReportName" | Out-File -FilePath $File -Append
# Indent the next level of the hierarchy 4 spaces.
$Offset = "$Offset "
}
}
}
# Search for all objects that report to this object.
$Filter = "(manager=$ReportDN)" [20]
}
# Run the query.
$Searcher.Filter = $Filter
$Results = $Searcher.FindAll()
If ($Results.Count -gt 0)
{
# Code not shown that outputs HTML tags.
ForEach ($Result In $Results) [21]
{
# Output the object.
$DN = $Result.Properties.Item("distinguishedName") [22]
If ($Output -eq "DN")
{
Switch ($Format)
{
"Text" {$Line = "$Offset$DN"}
}
}
If ($Output -eq "Name")
{
# Retrieve name and sAMAccountName. [23]
$Name = $Result.Properties.Item("name")
$NTName = $Result.Properties.Item("sAMAccountName")
Switch ($Format)
{
"Text" {$Line = "$Offset$Name ($NTName)"}
}
}
$Line | Out-File -FilePath $File -Append [24]
# Retrieve any direct reports for this object.
$Reports = $Result.Properties.Item("directReports") [25]
If ($Reports.Count -gt 0)
{
# Code not shown that outputs HTML tags.
ForEach ($Report In $Reports)
{
# Recursively call this function for each direct report.
# Increase any indenting by 4 more spaces.
Get-Reports $Report $DN "$Name ($NTName)" "$Offset " [26]
}
# Code not shown that outputs HTML tags.
}
}
# Code not shown that outputs HTML tags.
}
}
This code executes every time the script finds a direct report. The first thing that happens is that the direct report is documented, with the DN or the Name and sAMAccountName [16]. If $Output is "Name", ADSI is used to retrieve the values of the Name and sAMAccountName attributes of the direct report [18]. Notice that any forward slashes in the distinguished name of the direct report are escaped with the backslash escape character [17]. Other characters that must be escaped in distinguished names, such as the comma, will be escaped when the distinguished names are retrieved. But the forward slash character only needs to be escaped when ADS is used, as here. The distinguished names retrieved will not have this character escaped, so this possibility must be accounted for.
The $ReportName variable is assigned the value "Name (sAMAccountName)" [19]. Next the code creates the LDAP filter needed to find all objects that possibly report to this direct report. The LDAP filter is now "(manager=$ReportDN)" [20]. The results are enumerated in a ForEach loop [21], where the distinguished name [22] (and if required, the Name and sAMAccountName values [23]) of each direct report are retrieved and output to the report file [24]. Finally, for each direct report [25], the Get-Reports function is called recursively again [26].
The steps taken by the script in this simple example (with Ann, Brandon, Charles, Fred, Jim, and Alice) can be outlined as follows:
- Get-Reports is called in the main program with $ReportDN equal to "Top".
- The query returns one object, Ann. The DN, Name, and sAMAccountNames are retrieved for Ann and output to the report file.
- The directReports attribute of Ann is retrieved and the values are enumerated in a ForEach loop. This will retrieve one direct report in the example, Brandon.
- The Get-Reports function is called recursively for each direct report of Ann (Brandon).
- Each direct report is first documented in the report file. If necessary, the Name and sAMAccountName attributes for each direct report is retrieved and documented.
- A new filter is constructed to query AD for anyone that reports to this direct report, Brandon. These will be objects where the manager attribute is the DN of the current direct report. This will return Charles and Fred
- Each new direct report at the next management level is handled in a ForEach loop. For Charles this returns Jim and Alice. For Fred this returns nothing.
- The DN, Name, and sAMAccountName values for each direct report are retrieved and documented in the report file.
- The Get-Reports function is called recursively as many times as necessary to handle all of the direct reports, at as many levels of organization hierarchy as necessary.
- Whenever a direct report has no direct reports, such as Fred, Jim, and Alice, the recursive function is not called and the function exits.
Output File Created by the Script
Consider the situation diagrammed in the image above (with Ann, Brandon, Charles, Fred, Jim, and Alice). If $Format is "Text" and $Output is "Name" the output file will appear as below when it opens in notepad:
If "HTML" format is selected instead, the file will appear as below in a Browser:
If "CSV" is selected, the resulting csv file should open in Excel:
Code for CSV and HTML Formats
There is a minor difference when the output format is a comma delimited (CSV) file. In this case the first row is the object at the top of the organization. This person has no manager. This person is documented in the direct report field, and the manager field is left blank. As each subsequent direct report is found, it is output on a line along with their manager.
"HTML" format is straightforward, except that extra tags must be added to the file. The tags <ul> and </ul> are used to create unordered lists. This replaces the indenting used in "Text" format. Each direct report is enclosed in <li> and </li> tags, to create an entry in the list. The unordered lists are nested to show the hierarchy. In addition, the entire output file is enclosed in <div> and </div> tags that specify the fixed-width font "Courier New".
Organization Chart
The last link in the "Other Resources" section below is a reference describing how to use the output file from this script to create an organization chart in Visio. This will only be practical if your organization is small. The comma delimited file created for the example organization was used to create an organization chart in Visio Professional 2013. The result is shown below.
See Also
- Active Directory: LDAP Syntax Filters
- Wiki: Active Directory Domain Services (AD DS) Portal
- Active Directory: Glossary
- Active Directory: Characters to Escape