. Первоначально я думал, что написать сценарий будет просто, но работа оказалась более длительной, чем ожидалось, из-за четырех проблем. Я расскажу о них, но сначала необходимо объяснить основы использования Microsoft.NET в PowerShell для поиска по AD.

Применение .NET для поиска по AD

При использовании .NET для поиска по AD можно задействовать акселератор типа [ADSISearcher] в PowerShell для обнаружения объектов. Например, введите следующие команды в PowerShell, чтобы получить список всех пользователей в текущем домене:

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

[ADSISearcher] — акселератор типа для объекта NET System.DirectoryServices.DirectorySearcher. Строка, следующая за акселератором типа, задает свойство SearchFilter объекта, чтобы найти все пользовательские объекты, а метод FindAll запускает поиск. На выходе формируется список объектов System.DirectoryServices.SearchResult.

Затем нужно определить членство пользователей в группах. Можно задействовать коллекцию Properties из объекта SearchResult, чтобы получить атрибут memberof объекта. Используя переменную $searcher из предыдущего примера, можно применить метод FindOne, чтобы получить один результат и выдать членства пользователей в группах:

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

Первая команда обнаруживает первого пользователя, соответствующего поисковому критерию, а вторая команда выводит список групп, к которым принадлежит пользователь.

Однако, если внимательно рассмотреть этот список, можно заметить отсутствующий элемент: основная группа пользователя не указана в атрибуте memberof. Я хотел получить полный список групп (в том числе основной группы), и здесь возникла первая проблема.

Проблема 1: поиск основной группы пользователя

В статье Microsoft «How to Use Native ADSI Components to Find the Primary Group» (support.microsoft.com/kb/321360) описан обходной прием для исключения основной группы из атрибута memberof. Он состоит из следующих действий.

  1. Подключитесь к объекту пользователя через поставщика WinNT (вместо поставщика LDAP).
  2. Получите атрибут primaryGroupID пользователя.
  3. Получите с помощью поставщика WinNT имена групп пользователя, среди которых будет основная группа.
  4. Выполните поиск этих групп в AD с использованием атрибутов sAMAccountName.
  5. Найдите группу, в которой первичный атрибут GroupToken совпадает с primaryGroupID пользователя.

Проблема состоит в том, что в сценарии необходимо задействовать поставщика WinNT для подключения к объекту пользователя. То есть сценарий должен преобразовать различающееся имя пользователя (DN; например, CN=Ken Myer, OU=Marketing, DC=fabrikam, DC=com) в формат, пригодный для поставщика WinNT (например, WinNT://FABRIKAM/kenmyer, User).

Проблема 2: преобразование между форматами именования

Объект NameTranslate представляет собой объект COM (ActiveX), который реализует интерфейс IADsNameTranslate для преобразования имен объектов AD в другие форматы. Используя объект NameTranslate, можно создать объект, а затем инициализировать его, вызвав метод Init. В листинге 1 показан исходный текст VBScript для создания и инициализации объекта NameTranslate.

Но, как показано на приведенном экране, объект NameTranslate функционирует в PowerShell иначе, чем можно было бы предполагать. Дело в том, что объект NameTranslate не имеет библиотеки типов, которая используется. NET (и, следовательно, PowerShell) для упрощения доступа к объектам COM. Можно поступить следующим образом: с помощью метода InvokeMember.NET получить или задать свойство или выполнить метод из объекта COM, не имеющего библиотеки типов. В листинге 2 показан программный код PowerShell, эквивалентный исходному тексту VBScript, приведенному в листинге 1.

 

Непредвиденное поведение объекта NameTranslate в PowerShell
Экран. Непредвиденное поведение объекта NameTranslate в PowerShell

Необходимо, чтобы сценарий устранил еще одну проблему, связанную с именами. Атрибут memberof для пользователя AD содержит список DN, членом которых является пользователь, но мне хотелось получить атрибут samaccountname для каждой группы. В интерфейсе Active Directory Users and Computers используется имя атрибута из времен, предшествующих Windows 2000. Эта проблема также решается в сценарии с помощью объекта NameTranslate.

Проблема 3: обработка специальных символов

В документации компании Micro­soft относительно DN отмечается, что для правильного восприятия определенных символов перед ними необходимо вводить символ «\» (msdn.microsoft.com/en-us/library/aa366101.aspx). К счастью, объект Pathname COM предоставляет такую возможность. В сценарии используется объект Pathname для выделения DN, содержащих специальные символы. Для объекта Pathname требуется также метод InvokeMember.NET, поскольку, как и объект NameTranslate, этот объект не имеет библиотеки типов.

Проблема 4: повышение производительности

Вернувшись к проблеме 1 (поиск основной группы пользователя), можно заметить, что для обходного решения требуется выполнить в AD поиск групп пользователя. Если повторить этот поиск для многих учетных записей пользователя, трудоемкость повторных поисков оказывается очень высокой. Получение атрибута samaccountname для каждой группы в атрибуте memberof, который упоминался при описании проблемы 2 (преобразование между форматами именования), также усложняет работу. Для решения этой проблемы сценарий использует две глобальные хэш-таблицы, которые кэшируют результаты для повышения производительности.

Get-UsersAndGroups.ps1

Get-UsersAndGroups.ps1 — завершенный сценарий PowerShell, который формирует список пользователей и членства пользователей в группах. Синтаксис командной строки сценария следующий:

Get-UsersAndgroups [[-SearchLocation]
   <Строка []>] [-searchscope <строка>]

Параметр -SearchLocation — одно или несколько различающихся имен (DN) для поиска учетных записей пользователя. Поскольку DN содержит запятые (,), заключите каждую DN в одинарные кавычки (‘) или двойные кавычки ("), чтобы PowerShell не воспринимал DN как массив. Параметр имени -SearchLocation — не обязательный. Сценарий также принимает конвейерный ввод; каждое значение из конвейера должно быть искомым различающимся именем.

Значение -SearchScope указывает возможный диапазон поиска в AD. Существует три варианта этого значения: Base (ограничить поиск базовым объектом, не используется), OneLevel (поиск непосредственных дочерних объектов базового объекта) или Subtree (поиск всего поддерева). Если не указано никакого значения, по умолчанию используется Subtree. Задействуйте -SearchScope OneLevel, если нужно выполнить поиск в определенной организационной единице (OU), но ни в одном из расположенных ниже OU. Сценарий выводит объекты, содержащие свойства, перечисленные в таблице.

 

Таблица. Свойства объекта сценария
Свойства объекта сценария

Устранение четырех проблем

Сценарий обеспечивает решение четырех обозначенных выше проблем.

  • Проблема 1 (поиск основной группы пользователя): функция get-primarygroupname возвращает имя основной группы для пользователя.
  • Проблема 2 (преобразование между форматами именования): сценарий использует COM-объект NameTranslate для преобразования между форматами именования.
  • Проблема 3 (обработка специальных символов): сценарий использует функцию getescaped, которая с помощью объекта Pathname возвращает различающиеся имена со вставкой соответствующих escape-символов.
  • Проблема 4 (повышение производительности): в сценарии используются $PrimaryGroups и $Groups. Ключами хэш-таблицы $PrimaryGroups являются идентификаторы основной группы, а ее значения — атрибуты samaccountname основной группы.

Аудит пользователей и групп — это просто

Процесс создания сценария Get-UsersAndGroups.ps1 (листинг 3) оказался сложнее, чем ожидалось, но работать с ним очень просто. Типичное применение сценария — в такой команде, как

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

Эта команда формирует CSV-файл (с разделением запятыми), в котором содержится полный список учетных записей пользователей и групп для текущего домена. С помощью сценария можно без труда создать отчет о пользователях и группах.

Листинг 1. Создание и инициализация объекта NameTranslate в VBScript

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

Листинг 2. Создание и инициализация объекта NameTranslate в PowerShell

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

Листинг 3. Get-UsersAndGroups.ps1

# 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("GetEscaped
Element", "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 $_
    }
  }
}

Билл Стюарт (bill.stewart@frenchmortuary.com) — системный и сетевой администратор компании French Mortuary, Нью-Мехико