PowerShell
14 November 2011

Актуализируем учетные данные Active Directory

Многие помнят то чувство, когда компания расширяется до тех размеров, когда рабочих групп недостаточно, и поднимается первый домен Active Directory: «О, уж теперь-то все будет как следует!» Ан нет, домен потихонечку разрастается, создаются новые учетки, блокируются старые, добавляются, удаляются компьютеры, девушки выходят замуж, меняют фамилии и, в конце концов, база данных службы каталогов выглядит, как полный швах. В этом топике мы наладим связь между базой Active Directory и кадровой системой предприятия, а также создадим механизм для поддержания данных сотрудников в AD в актуальном состоянии.

Первым делом, мы опишем требования, которые мы должны предъявить к учетным записям сотрудников. А эти требования мы постараемся прикинуть, исходя из потребностей пользователя. Не секрет, что многие корпоративные системы, использующие аутентификацию через Active Directory, для отображения и в своих админках, и просто в ходе работы зачастую используют разнообразные поля учетных записей AD: это и Sharepoint, и Citrix, и многие-многие другие. В качестве примера такой системы я возьму известный всем MS Outlook, да не полностью, а лишь его адресную книгу, которая черпает свои данные напрямую из Active Directory.



Что использует пользователь? У нас в организации он зачастую ищет по имени телефон, адрес электронной почты и название подразделения. Конечно, неплохо заполнить еще и адресную информацию, но в связи с тем, что топик у нас о связке с абстрактной кадровой системой, мы адреса и телефоны оставим за скобками.
Первое, мы выписываем список полей, которые мы желаем забирать из кадровой системы, у нас это будут:
  • Фамилия Имя Отчество
  • Должность
  • Организация
  • Подразделение
  • Почтовый индекс
  • Тип занятости
На этом этапе полезно закрепить наш перечень полей и издать Приказ Именем Самого Большого Директора, обязывающий кадровиков и администраторов поддерживать эти данные в актуальном состоянии.

Механизм


Само собой ясно, что для того, чтобы связать персону из кадровой системы и персону из Active Directory, необходимо иметь некий идентификатор, связывающий эти две записи. Обычно таким идентификатором является табельный номер сотрудника, он присваивается при приеме на работу и более никогда не меняется, вместе с тем, я встречался с ситуациями, когда и табельный номер не статичен, в этом случае такой идентификатор следует выдумать.

Сведения о пользователе Active Directory не исчерпываются сведениями, которые можно увидеть в оснастке Active Directory Users and Computers (устоявшееся сокращение ADUC), причем очень далеко не исчерпываются. На самом деле объект пользователя имеет триллион атрибутов, и эти атрибуты даже могут быть добавлены администратором схемы. Например, есть такой атрибут, как carLicense, содержащий информацию о водительском удостоверении, или drink, характеризующий любимый напиток пользователя. В общем, Microsoft в этом смысле предусмотрела многое.

Использовать в моем примере я буду атрибуты employeeID для хранения идентификатора пользователя, и flags, для чего именно, сообщу чуть позже.

Также для заполнения полей пользователя мы будем использовать атрибуты:
  • displayName и CN для хранения ФИО
  • department для хранения подразделения
  • company для хранения организации
  • title для хранения должности
  • employeeType для хранения типа сотрудника
  • postalCode для хранения индекса
Педанты могут, конечно, дополнительно использовать givenName, initials и sn для хранения имени, инициалов и фамилии соответственно, но я думаю, что это уже тонкости.

Итак, наше приложение будет работать таким образом:
  1. Перечислять учетные записи, у которых заполнен employeeID
  2. Искать в кадровой системе для каждой учетной записи изменившиеся данные
  3. Обновлять данные в Active Directory
  4. Протоколировать изменения в файле

К делу


Первым делом следует проставить employeeID, который у нас представляет табельный номер, всем пользователям. Если пользователей мало, то сделать это проще всего через ADSI Edit, если их чуть больше, то можно прикрутить скрипт для прописывания, например вот так. А если пользователей много, расстановку идентификаторов необходимо делегировать, хочется приятный интерфейс и используются дополнительные фенечки, то можно создать вот такую дополнительную вкладку в ADUC:



впрочем, создание такой вкладки это само по себе тема для отдельного топика.

Второе тонкое место в том, что иногда случается так, что для некоторых людей менять следует только некоторые атрибуты. Есть, например, у нас сотрудник, назовем его Кудрымунбеков Садруддин Фатхулларович, но все его называют просто Сан Саныч. А есть генеральный директор, должность которого в кадрах записана не иначе, как Генеральный Директор Открытого Акционерного Общества Дальней Космической Связи «Рога И Копыта», которого в AD лучше бы просто оставить точно со связью, но точно без рогов и копыт. Таким образом, мы видим необходимость в закладывании в логику работы нашего приложения некоторых исключений, а хранить эти исключения мы будем там же, в Active Directory в атрибуте flags. Этот атрибут имеет величину в четыре байта, а значит, устанавливая тот или иной бит в то или иное значение, мы сможем при необходимости запомнить аж 32 исключения. Впрочем, использовать мы все равно будем только шесть.

Переходим к реализации на powershell:

# Пример изменения учетных записей пользователей в Active Directory
# Егор Иванов

param($strServer, $strContainer, $strUserName, $strPassword, $strFileName, $strLogName)

function Write-LogFile([string]$logFileName)
{
    Process
    {
        $_
        $dt = Get-Date
        $str = $dt.DateTime + " " + $_
        $str | Out-File -FilePath $logFileName -Append
    }
}

# Эта функция на самом деле заглушка, ее реализация зависит
# от той или иной кадровой системы. Тут может быть и соединение с 1С,
# и запрос в веб, у меня лично тут ковыряние Oracle e-Buisness suite,
# но демонстрации ради мы ограничимся чтением из csv-файла.
# Понятное дело, что вызывать каждый раз Import-CSV глупо,
# но как я и говорил, функция - заглушка, она лишь демонстрирует возможность

function Get-Employee($employeeID, $fileName, [ref]$title, [ref]$department, [ref]$displayName, [ref]$company, [ref]$postalCode, [ref]$employeeType)
{
    $records = $fileName | Import-CSV -Delimiter ";"
    $employee =  $records | where-object {$_.EmployeeID -eq $employeeID}
    if ($employee -eq $null) {return $false}
    $title.Value = [string]$employee.Title
    $department.Value = [string]$employee.Department
    $displayName.Value = [string]$employee.Name
    $company.Value = [string]$employee.Company
    $postalCode.Value = [string]$employee.PostalCode
    $employeeType.Value = [string]$employee.EmployeeType
    return $true
}

# Будем писать в лог

"---" | Write-LogFile $strLogName
"Запускаю с параметрами:" | Write-LogFile $strLogName
"Сервер: " + $strServer | Write-LogFile $strLogName
"Контейнер: " + $strContainer | Write-LogFile $strLogName
"Имя пользователя: " + $strUserName | Write-LogFile $strLogName
"Пароль: " + $strPassword | Write-LogFile $strLogName
"Имя файла: " +$strFileName | Write-LogFile $strLogName
"Имя файла лога: " + $strLogName | Write-LogFile $strLogName

# Это наши константы, которые переводят те или иные атрибуты в режим только чтения
# Нетрудно заметить, что они имеют значения 000001, 000010, 000100, 001000, 010000 и 100000
# в двоичной системе. Это значит, что их комбинация однозначно определит,
# какие поля запретить изменять

New-Variable -Option constant -Name C_COMPANY_FLAG -Value 1
New-Variable -Option constant -Name C_POSTALCODE_FLAG -Value 2
New-Variable -Option constant -Name C_TITLE_FLAG -Value 4
New-Variable -Option constant -Name C_DEPARTMENT_FLAG -Value 8
New-Variable -Option constant -Name C_NAME_FLAG -Value 16
New-Variable -Option constant -Name C_EMPLOYEETYPE_FLAG -Value 32

# Ниже заглушка для атрибута title. Атрибут title к примеру
# http://msdn.microsoft.com/en-us/library/windows/desktop/ms680037(v=VS.85).aspx
# имеет 64 символа максимальный размер в Windows Server 2003
# или 128 символов максимальный размер в Windows Server 2008
# поэтому если не обрезать значение, случится конфуз

New-Variable -Option constant -Name C_PARAMETERS_LENGTH -Value 64

# (!userAccountControl:1.2.840.113556.1.4.803:=2) читать как "и при этом учетка не заблокирована"

$strFilter = "(&(objectClass=user)(!objectClass=computer)(employeeID=*)(!userAccountControl:1.2.840.113556.1.4.803:=2))"

# Можно, конечно, использовать навески для Active Directory под Windows Server 2008
# http://blogs.msdn.com/adpowershell
# Но я решил сделать приложение совместимым с Windows Server 2003 и Windows XP,
# поэтому обойдемся сухим дотнетом 

$objDomain = New-Object System.DirectoryServices.DirectoryEntry("LDAP://"+$strServer+"/"+$strContainer)
$objSearcher = New-Object System.DirectoryServices.DirectorySearcher
$objSearcher.SearchRoot = $objDomain
$objSearcher.PageSize = 1000
$objSearcher.Filter = $strFilter
$objSearcher.SearchScope = "Subtree"
$colProplist = "employeeID","postalCode","title","department", "displayName", "cn", "employeeType"
foreach ($i in $colPropList)
{
    $objSearcher.PropertiesToLoad.Add($i)
}
$colResults = $objSearcher.FindAll()

# Теперь в colResults мы имеем все необходимые учетки

$startTime = Get-Date
$totalCount = $colResults.Count
$i = 0
foreach ($objResult in $colResults)
{
    $objItem = $objResult.Properties
    $aDEmployeeID = $objItem.employeeid
    
    # Тут ясно, мы смотрим в атрибут flags, если у нас есть запрет на изменение
    # поля, то мы это на будущее запоминаем, поднимая тот или иной флажок

    $flagProtectCompany = $false
    $flagProtectPostalCode = $false
    $flagProtectTitle = $false
    $flagProtectDepartment = $false
    $flagProtectName = $false
    $flagProtectEmployeeType = $false
    
    if (!($objItem.flags -eq $null))
    {
        $flags = $objItem.flags
        if (($flags[0] -band $C_COMPANY_FLAG) -ne 0) {$flagProtectCompany = $true}
        if (($flags[0] -band $C_POSTALCODE_FLAG) -ne 0) {$flagProtectPostalCode = $true}
        if (($flags[0] -band $C_TITLE_FLAG) -ne 0) {$flagProtectTitle = $true}
        if (($flags[0] -band $C_DEPARTMENT_FLAG) -ne 0) {$flagProtectDepartment = $true}
        if (($flags[0] -band $C_NAME_FLAG) -ne 0) {$flagProtectName = $true}
        if (($flags[0] -band $C_EMPLOYEETYPE_FLAG) -ne 0) {$flagProtectEmployeeType = $true}
    }
    
    # Это не обязательно, но я предпочитаю все обнулить
        
    $cSVName = ""
    $cSVTitle = ""
    $cSVDepartment = ""
    $cSVCompany = ""
    $cSVPostalCode = ""
    $cSVEmployeeType = ""
    
    # Тут следует обратить внимание на вызов функции в PowerShell, он не совсем
    # такой, как в привычных языках
    
    $rc = Get-Employee $aDEmployeeID $strFileName ([ref]$cSVTitle) ([ref]$cSVDepartment) ([ref]$cSVName) ([ref]$cSVCompany) ([ref]$cSVPostalCode) ([ref]$cSVEmployeeType)
        
    if ($rc)
    {
        # Здесь мы соединяемся со службой каталогов уже под другим именем и паролем, нежели был
        # запущен сценарий. Неразумно осуществлять изменения от имени администратора домена,
        # разумнее делегировать изменения того, того, того и сего атрибута служебной учетке
        # с ограниченными правами

        $objDirectoryEntry = new-object System.DirectoryServices.DirectoryEntry($objItem.adspath, $strUsername, $strPassword, [System.DirectoryServices.AuthenticationTypes]::Secure)
        
        $oTitle = $cSVTitle
        if ($oTitle.Length -gt $C_PARAMETERS_LENGTH) {$oTitle = $oTitle.Substring(0,$C_PARAMETERS_LENGTH)}
        $oDepartment = $cSVDepartment
        if ($oDepartment.Length -gt $C_PARAMETERS_LENGTH) {$oDepartment = $oDepartment.Substring(0,$C_PARAMETERS_LENGTH)}
        $newEmployeeType = $cSVEmployeeType
        
        # Здесь и далее мы проверяем, совпадает ли то значение, которое мы хотим присвоить, тому значению,
        # которое уже присвоено (и не забываем про флажок). Это чтоб не напрягать службу каталогов
        
        if (($newEmployeeType -ne $objItem.employeetype) -and -not $flagProtectEmployeeType)
        {
            "Изменение EmployeeType пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.employeetype + """ на """ + $newEmployeeType + """" | Write-LogFile $strLogName
            $objDirectoryEntry.employeetype = [string]$newEmployeeType
            $objDirectoryEntry.CommitChanges()
        }       
        if (($cSVCompany -ne $objItem.company) -and -not $flagProtectCompany)
        {
            "Изменение организации пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.company + """ на """ + $cSVCompany + """" | Write-LogFile $strLogName
            $objDirectoryEntry.company = [string]$cSVCompany
            $objDirectoryEntry.CommitChanges()
        }       
        if (($cSVPostalCode -ne $objItem.postalcode) -and -not $flagProtectPostalCode)
        {
            "Изменение индекса пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.postalCode + """ на """ + $cSVPostalCode + """" | Write-LogFile $strLogName
            $objDirectoryEntry.postalCode = $cSVPostalCode
            $objDirectoryEntry.CommitChanges()
        }
        if (($oTitle -ne $objItem.title) -and -not $flagProtectTitle)
        {
            "Изменение должности пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.title + """ на """ + $cSVTitle + """" | Write-LogFile $strLogName
            if ($title.Length -gt $C_PARAMETERS_LENGTH)
            {
                $objDirectoryEntry.title = $cSVTitle.Substring(0,$C_PARAMETERS_LENGTH)
            }
            else
            {
                $objDirectoryEntry.title = $cSVTitle.ToString()
            }
            $objDirectoryEntry.CommitChanges()
        }
        if (($oDepartment -ne $objItem.department) -and -not $flagProtectDepartment)
        {
            "Изменение подразделения пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.department + """ на """ + $cSVDepartment + """" | Write-LogFile $strLogName
            if ($department.Length -gt $C_PARAMETERS_LENGTH)
            {
                $objDirectoryEntry.department = $cSVDepartment.Substring(0,$C_PARAMETERS_LENGTH)
            }
            else
            {
                $objDirectoryEntry.department = $cSVDepartment.ToString()
            }
            $objDirectoryEntry.description = $cSVDepartment.ToString()
            $objDirectoryEntry.CommitChanges()
        }
        if ((($cSVName -ne $objItem.displayname) -or ($cSVName -ne $objItem.cn)) -and -not $flagProtectName)
        {
            "Изменение имени пользователя """ + $objDirectoryEntry.name + """" | Write-LogFile $strLogName
            "с """ + $objDirectoryEntry.displayname + """ на """ + $cSVName + """" | Write-LogFile $strLogName
            $objDirectoryEntry.displayName = $cSVName
            $objDirectoryEntry.CommitChanges()
            $objDirectoryEntry.Rename("cn="+$cSVName)
        }
        $i++
        
        # Здесь мы сформируем статусную строку и будем ее демонстрировать, простые математические действия
        # укажут нам, когда же, наконец, процесс прочесывания миллиардов наших пользователей прекратится
        
        $status = $i.ToString() + " of " + $totalCount.ToString() + " complete - " + $objDirectoryEntry.name
        $currentTime = Get-Date
        $diffTime = [int][System.Math]::Round(($currentTime - $startTime).Ticks / $i)
        $delta = $diffTime*$totalCount
        $endTime = $startTime.Add([int64]($delta)) 
        $activityString = "Перебор пользователей. Расчетное время завершения " + $endTime
        Write-Progress -Activity $activityString -Status $status -PercentComplete (($i / $totalCount) * 100)
    }
}
"Работа окончена" | Write-LogFile $strLogName

# И не забыть пропищать из динамика, мало ли

Write-Host `a


Создадим тестовую среду, абсолютно произвольно присвоим имена учетным записям:



Теперь запускаем скрипт с параметрами. Разумеется, его можно зашедулить и выполнять по расписанию, только не забыть учетной записи, от имени которой выполняется задание, присвоить право входа в качестве пакетного задания:



Как видим, после выполнения, мы получили хорошие читаемые имена, отличные должности и великолепные наименования компаний:



Конечно, путем неглубокой модификации скрипта, мы можем заполнять пользователю все: от телефонов и адресов до пресловутых любимых напитков, был бы источник. А если скрипт запускать с определенной регулярностью, то мы добиваемся того, что все данные о пользователях будут актуальны.

upd Подправил маленькую ошибочку, перенес обнуление флажков во внутрь цикла

+28
64.9k 238
Comments 21
Top of the day