Building a report on user membership in AD groups: 4 problems in writing a Powershell script


    Bill Stewart, scripting guru, in his article on WindowsITPro describes the problems that he faced when writing a Powershell script that would display user membership in Active Directory groups. I had to make 4 improvements so that everything worked as it should. You can find out how Bill implemented the conclusion of group membership, and you can download the Powershell script itself under the cut.

    Link to the final version of the script.
    www.windowsitpro.com/content/content/141463/141463.zip

    I lost count, how many times have I met on the forums the question: “Does anyone know how to get information about all users and their group membership in the AD domain?”. Auditors and information security consultants also ask a similar question when they evaluate the Active Directory infrastructure (environment) in the organization. Since this issue is quite urgent, I decided to write a PowerShell script that would simplify this task.
    At first, I thought that writing a similar script was a couple of trifles, but 4 obstacles met in my way, which complicated my work. I will describe these problems a bit later, but first I would like to talk about the basics of using Microsoft.NET in Powershell when searching through AD.

    Using .NET for AD Search



    Using .NET to search through AD, you can use type accelerator in PowerShell to search for objects. (Type accelerator is an abbreviated name for a .NET class). For example, enter the following command to list all users in this domain:

    PS C:\> $searcher =  "(&(objectCategory=user)(objectClass=user))"
    PS C:\> $searcher.FindAll()
    


    [ADSISearcher] is a type accelerator for the .NET System.DirectoryServices.DirectorySearcher .NET object. The line following this type of accelerator sets the SearchFilter properties for this object to find all user objects, and the FindAll method starts the search. At the output, we get a list of System.DirectoryServices.SearchResult objects .
    Then we want to determine which group the user is in. To find out, we can use the Properties collection from the SearchResult object and retrieve an object attribute such as memberof. Using the $ searcher variable from the previous example, we can use the FindOne method (instead of FindAll) to extract one result and deduce user membership in groups:

    PS C:\> $result = $searcher.FindOne()
    PS C:\> $result.Properties["memberof"] | sort-object
    


    The first command finds the first user that matches the search filter, and the second command lists the groups that the user is in.
    However, if you look closely at this list, you will notice the absence of an important detail: the user's primary group is not included in the memberof attribute . I would like to get a complete list of groups (including the main group), which leads us to the first problem.

    Problem # 1: How to find the main user group



    There is a workaround to exclude the main group from the memberof attribute. It is described in this article support.microsoft.com/kb/321360 We perform the following steps:
    1. We connect (connect to) with the user object using WinNT provider (instead of LDAP provider).
    2. Retrieve the user attribute primaryGroupID.
    3. Retrieve user group names using the WinNT provider, which includes the main group.
    4. Search AD for these groups using their sAMAccountName attributes.
    5. We find a group in which the primaryGroupToken attribute matches the user attribute primaryGroupID .

    The problem with this “workaround” is that it requires a WinNT provider script to connect to the user object. That is, it is necessary for the script to translate the distinguished username (for example, CN = Ken Myer, OU = Marketing, DC = fabrikam, DC = com) into a format that WinNT provider can use (for example, WinNT: // FABRIKAM / kenmyer, User).

    Problem # 2: Translation from one name format to another



    The NameTranslate object is a COM (ActiveX) object that uses the IADsNameTranslate interface , which translates the names of AD objects into variables (alternate) formats. You can use the NameTranslate object by creating an object and then calling its Init method to initialize. For example, list 1 shows the VBScript code for the script that the NameTranslate creates and initializes .

    List 1: Creating and Initializing a NameTranslate in VBScript

    Const ADS_NAME_INITTYPE_GC = 3
    Dim NameTranslate
    Set NameTranslate = CreateObject("NameTranslate")
    NameTranslate.Init ADS_NAME_INITTYPE_GC, vbNull
    


    However, the NameTranslate does not work as expected in PowerShell, as shown in Figure 1.


    Figure 1: Unexpected behavior of the NameTranslate in PowerShell

    The problem is that the NameTranslate does not have a type library that .NET (and therefore PowerShell) uses to provide easy access to COM objects. But fortunately, this problem can be circumvented: the .NET InvokeMember method allows PowerShell to get or set properties or call a method from a COM object that is not in the type library. List 2 shows Powershell equivalent to the VBScript code in Table 1

    List 2: Create and initialize a NameTranslate in PowerShell

    $ADS_NAME_INITTYPE_GC = 3
    $NameTranslate = new-object -comobject NameTranslate
    [Void] $NameTranslate.GetType().InvokeMember("Init", "InvokeMethod",
      $NULL, $NameTranslate, ($ADS_NAME_INITTYPE_GC, $NULL))
    


    I wanted the script to solve another problem related to the name. The memberof attribute for AD contains a list of distinguished names that the user is a member of, but instead I wanted to get the samaccountname attribute for each group. The script uses the NameTranslate object to deal with this problem.

    Problem # 3: What to do with special characters



    The Microsoft documentation regarding distinguished names mentions that individual characters must be omitted (for example, with the prefix “\”) in order to be correctly interpreted ( this article is written in more detail). Fortunately, the Pathname COM object provides this capability. The script uses the Pathname object to skip those distinguished names that contain special characters. The Pathname object also requires the .NET InvokeMember method because, like the NameTranslate object , this object does not have a type library.

    Problem # 4: Improving Productivity



    If you look at Problem # 1 (How to find the main user group), you will notice that a workaround requires searching for user groups. Having done this procedure for several accounts, you will understand how it is not optimal. Retrieving the samaccountname attribute for each group in the memberof attribute that I mentioned, considering Problem # 2 (Translation from one name format to another) is also not optimal and time-consuming. To solve this problem, the script uses two global hash tables, which hash the results to improve performance.

    Get-UsersAndGroups.ps1



    Get-UsersAndGroups.ps1 is a ready-made Powershell script that displays a list of users and their group memberships. The script command line syntax is as follows:
     
    Get-UsersAndGroups [[-SearchLocation] ] [-SearchScope ]
    


    The -SearchLocation parameter represents one or more distinguished names for user accounts. Because the distinguished name contains commas (,), they must be placed in brackets (single or double) for each distinguished name so that PowerShell does not interpret them as an array. The parameter name -SearchLocation is optional. The script also accepts pipeline input; each value from the pipeline must be a distinguished name to look for.
    The value -SearchScope indicates the possible extent of the AD search. This value should be one of three: Base - Search is limited to the base object, not used; Onelevel- search for the nearest children of the base object and Subtree - search by backlight. If this value is not specified, then Subtree is used by default. Use -SearchScope OneLevel if you want a specific organizational unit (OU), but none of the OU is nested in it. The script displays objects that contain the properties listed in table 1.



    Overcoming 4 Challenges



    The script solves the above problems:
    • Problem # 1: How to find the primary user group: The get-primarygroupname function returns the name of the primary user group.
    • Problem # 2: Translation from one name format to another: The script uses the NameTranslate COM object to translate from one name format to another.
    • Problem # 3: What to do with special characters: The script uses the get-escaped function , which uses the Pathname object to return distinguished names with missing characters inserted (where necessary).
    • Problem # 4: Improving performance: The script uses hash tables $ PrimaryGroups and $ Groups . The keys in the $ PrimaryGroups hash table are the identifiers of the main group and their values ​​are the attributes of the samaccountname of the main group.


    Simplify group and user auditing


    Writing the Get-UsersAndGroups.ps1 script turned out to be not so simple as it seemed to me at first glance, but it could not be easier. The simplest script application is the following command:

    PS C:\> Get-UsersAndGroups | Export-CSV Report.csv -NoTypeInformation
    


    It creates a .csv file that contains a complete list of users and groups for a given domain. The name in your arsenal is such a script, we can quickly and easily create a report by groups and users.

    Once again, duplicate the link to the final version of the script.
    www.windowsitpro.com/content/content/141463/141463.zip

    The script itself:

    # Get-UsersAndGroups.ps1
    # Written by Bill Stewart (bstewart@iname.com)
    #requires -version 2
    <#
    .SYNOPSIS
    Retreves users, and group membership for each user, from Active Directory.
    .DESCRIPTION
    Retreves users, and group membership for each user, from Active Directory. Note that each user's primary group is included in the output, and caching is used to improve performance.
    .PARAMETER SearchLocation
    Distinnguished name (DN) of where to begin searching for user accounts; e.g. "OU=Information Technology,DC=fabrikam,DC=com". If you omit this parameter, the default is the current domain (e.g., "DC=fabrikam,DC=com").
    .PARAMETER SearchScope
    Specifies the scope for the Active Directory search. Must be one of the following values: Base (Limit the search to the base object, not used), OneLevel (Searches the immediate child objects of the base object), or Subtree (Searches the whole subtree, including the base object and all its child objects). The default value is Subtree. To search only a location but not its children, specify OneLevel.
    .OUTPUTS
    PSObjects containing the following properties:
      DN        The user's distinguished name
      CN        The user's common name
      UserName  The user's logon name
      Disabled  True if the user is disabled; false otherwise
      Group     The groups the user is a member of (one object per group)
    #>
    [CmdletBinding()]
    param(
      [parameter(Position=0,ValueFromPipeline=$TRUE)]
        [String[]] $SearchLocation="",
        [String][ValidateSet("Base","OneLevel","Subtree")] $SearchScope="Subtree"
    )
    begin {
      $ADS_NAME_INITTYPE_GC = 3
      $ADS_SETTYPE_DN = 4
      $ADS_NAME_TYPE_1779 = 1
      $ADS_NAME_TYPE_NT4 = 3
      $ADS_UF_ACCOUNTDISABLE = 2
      # Assume pipeline input if SearchLocation is unbound and doesn't exist.
      $PIPELINEINPUT = (-not $PSBOUNDPARAMETERS.ContainsKey("SearchLocation")) -and (-not $SearchLocation)
      # If -SearchLocation is a single-element array containing an emty string
      # (i.e., -SearchLocation not specified and no pipeline), then populate with
      # distinguished name of current domain. In this case, input is not coming
      # from the pipeline.
      if (($SearchLocation.Count -eq 1) -and ($SearchLocation[0] -eq "")) {
        try {
          $SearchLocation[0] = ([ADSI] "").distinguishedname[0]
        }
        catch [System.Management.Automation.RuntimeException] {
          throw "Unable to retrieve the distinguished name for the current domain."
        }
        $PIPELINEINPUT = $FALSE
      }
      # These hash tables cache primary groups and group names for performance.
      $PrimaryGroups = @{}
      $Groups = @{}
      # Create and initialize a NameTranslate object. If it fails, throw an error.
      $NameTranslate = new-object -comobject "NameTranslate"
      try {
        [Void] $NameTranslate.GetType().InvokeMember("Init", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_INITTYPE_GC, $NULL))
      }
      catch [System.Management.Automation.MethodInvocationException] {
        throw $_
      }
      # Create a Pathname object.
      $Pathname = new-object -comobject "Pathname"
      # Returns the last two elements of the DN using the Pathname object.
      function get-rootname([String] $dn) {
        [Void] $Pathname.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $Pathname, ($dn, $ADS_SETTYPE_DN))
        $numElements = $Pathname.GetType().InvokeMember("GetNumElements", "InvokeMethod", $NULL, $Pathname, $NULL)
        $rootName = ""
        ($numElements - 2)..($numElements - 1) | foreach-object {
          $element = $Pathname.GetType().InvokeMember("GetElement", "InvokeMethod", $NULL, $Pathname, $_)
          if ($rootName -eq "") {
            $rootName = $element
          }
          else {
            $rootName += ",$element"
          }
        }
        $rootName
      }
      # Returns an "escaped" copy of the specified DN using the Pathname object.
      function get-escaped([String] $dn) {
        [Void] $Pathname.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $Pathname, ($dn, $ADS_SETTYPE_DN))
        $numElements = $Pathname.GetType().InvokeMember("GetNumElements", "InvokeMethod", $NULL, $Pathname, $NULL)
        $escapedDN = ""
        for ($n = 0; $n -lt $numElements; $n++) {
          $element = $Pathname.GetType().InvokeMember("GetElement", "InvokeMethod", $NULL, $Pathname, $n)
          $escapedElement = $Pathname.GetType().InvokeMember("GetEscapedElement", "InvokeMethod", $NULL, $Pathname, (0, $element))
          if ($escapedDN -eq "") {
            $escapedDN = $escapedElement
          }
          else {
            $escapedDN += ",$escapedElement"
          }
        }
        $escapedDN
      }
      # Return the primary group name for a user. Algorithm taken from
      # http://support.microsoft.com/kb/321360
      function get-primarygroupname([String] $dn) {
        # Pass DN of user to NameTranslate object.
        [Void] $NameTranslate.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_TYPE_1779, $dn))
        # Get NT4-style name of user from NameTranslate object.
        $nt4Name = $NameTranslate.GetType().InvokeMember("Get", "InvokeMethod", $NULL, $NameTranslate, $ADS_NAME_TYPE_NT4)
        # Bind to user using ADSI's WinNT provider and get primary group ID.
        $user = [ADSI] "WinNT://$($nt4Name.Replace('\', '/')),User"
        $primaryGroupID = $user.primaryGroupID[0]
        # Retrieve user's groups (primary group is included using WinNT).
        $groupNames = $user.Groups() | foreach-object {
          $_.GetType().InvokeMember("Name", "GetProperty", $NULL, $_, $NULL)
        }
        # Query string is sAMAccountName attribute for each group.
        $queryFilter = "(|"
        $groupNames | foreach-object { $queryFilter += "(sAMAccountName=$($_))" }
        $queryFilter += ")"
        # Build a DirectorySearcher object.
        $searchRootDN = get-escaped (get-rootname $dn)
        $searcher = [ADSISearcher] $queryFilter
        $searcher.SearchRoot = [ADSI] "LDAP://$searchRootDN"
        $searcher.PageSize = 128
        $searcher.SearchScope = "Subtree"
        [Void] $searcher.PropertiesToLoad.Add("samaccountname")
        [Void] $searcher.PropertiesToLoad.Add("primarygrouptoken")
        # Find the group whose primaryGroupToken attribute matches user's
        # primaryGroupID attribute.
        foreach ($searchResult in $searcher.FindAll()) {
          $properties = $searchResult.Properties
          if ($properties["primarygrouptoken"][0] -eq $primaryGroupID) {
            $groupName = $properties["samaccountname"][0]
            return $groupName
          }
        }
      }
      # Return a DN's sAMAccount name based on the distinguished name.
      function get-samaccountname([String] $dn) {
        # Pass DN of group to NameTranslate object.
        [Void] $NameTranslate.GetType().InvokeMember("Set", "InvokeMethod", $NULL, $NameTranslate, ($ADS_NAME_TYPE_1779, $dn))
        # Return the NT4-style name of the group without the domain name.
        $nt4Name = $NameTranslate.GetType().InvokeMember("Get", "InvokeMethod", $NULL, $NameTranslate, $ADS_NAME_TYPE_NT4)
        $nt4Name.Substring($nt4Name.IndexOf("\") + 1)
      }
      function get-usersandgroups2($location) {
        # Finds user objects.
        $searcher = [ADSISearcher] "(&(objectCategory=User)(objectClass=User))"
        $searcher.SearchRoot = [ADSI] "LDAP://$(get-escaped $location)"
        # Setting the PageSize property prevents limiting of search results.
        $searcher.PageSize = 128
        $searcher.SearchScope = $SearchScope
        # Specify which attributes to retrieve ([Void] prevents output).
        [Void] $searcher.PropertiesToLoad.Add("distinguishedname")
        [Void] $searcher.PropertiesToLoad.Add("cn")
        [Void] $searcher.PropertiesToLoad.Add("samaccountname")
        [Void] $searcher.PropertiesToLoad.Add("useraccountcontrol")
        [Void] $searcher.PropertiesToLoad.Add("primarygroupid")
        [Void] $searcher.PropertiesToLoad.Add("memberof")
        # Sort results by CN attribute.
        $searcher.Sort = new-object System.DirectoryServices.SortOption
        $searcher.Sort.PropertyName = "cn"
        foreach ($searchResult in $searcher.FindAll()) {
          $properties = $searchResult.Properties
          $dn = $properties["distinguishedname"][0]
          write-progress "Get-UsersAndGroups" "Searching $location" -currentoperation $dn
          $cn = $properties["cn"][0]
          $userName = $properties["samaccountname"][0]
          $disabled = ($properties["useraccountcontrol"][0] -band $ADS_UF_ACCOUNTDISABLE) -ne 0
          # Create an ArrayList containing user's group memberships.
          $memberOf = new-object System.Collections.ArrayList
          $primaryGroupID = $properties["primarygroupid"][0]
          # If primary group is already cached, add the name to the array;
          # otherwise, find out the primary group name and cache it.
          if ($PrimaryGroups.ContainsKey($primaryGroupID)) {
            [Void] $memberOf.Add($PrimaryGroups[$primaryGroupID])
          }
          else {
            $primaryGroupName = get-primarygroupname $dn
            $PrimaryGroups.Add($primaryGroupID, $primaryGroupName)
            [Void] $memberOf.Add($primaryGroupName)
          }
          # If the user's memberOf attribute is defined, find the group names.
          if ($properties["memberof"]) {
            foreach ($groupDN in $properties["memberof"]) {
              # If the group name is aleady cached, add it to the array;
              # otherwise, find out the group name and cache it.
              if ($Groups.ContainsKey($groupDN)) {
                [Void] $memberOf.Add($Groups[$groupDN])
              }
              else {
                $groupName = get-samaccountname $groupDN
                $Groups.Add($groupDN, $groupName)
                [Void] $memberOf.Add($groupName)
              }
            }
          }
          # Sort the ArrayList and output one object per group.
          $memberOf.Sort()
          foreach ($groupName in $memberOf) {
            $output = new-object PSObject
            $output | add-member NoteProperty "DN" $dn
            $output | add-member NoteProperty "CN" $cn
            $output | add-member NoteProperty "UserName" $userName
            $output | add-member NoteProperty "Disabled" $disabled
            $output | add-member NoteProperty "Group" $groupName
            $output
          }
        }
      }
    }
    process {
      if ($PIPELINEINPUT) {
        get-usersandgroups2 $_
      }
      else {
        $SearchLocation | foreach-object {
          get-usersandgroups2 $_
        }
      }
    }
    


    via WindowsITPro

    P.S. For a variety of reports on AD structure and changes, you can use NetWrix AD Change Reporter . The program allows you to keep abreast of changes in AD and at the same time does not require you to tedious work with logs or manual automation through scripts. You can learn more about the program on the NetWrix website.

    Also popular now: