Добрый день. Хочу описать практическое решение одной скучной, но очень живой задачи второй линии техподдержки: массовое заведение пользователей в Active Directory.
Исходные данные были такие: два территориально распределённых домена AD примерно по 10 000 человек, RemoteApp, несколько внутренних информационных систем и постоянный прирост базы — около 500 новых учётных записей в месяц. Это примерно 24 пользователя в рабочий день. Вручную через оснастку такое ещё можно делать день-два, но на дистанции это превращается в поток ошибок: опечатки в ФИО, разные форматы логинов, забытые группы, пользователи не в тех OU.
Задача была не написать «идеальную IDM-систему», а быстро и предсказуемо снять ручную рутину: взять утверждённую заявку, привести данные к единому виду, создать учётную запись, добавить нужные группы и оставить понятный след для разбора ошибок.
Что автоматизируем
Сам процесс выглядит просто, но именно простые процессы чаще всего ломаются на мелочах.

Этап | Что происходит | Что должно быть проверено |
|---|---|---|
Заявка | Руководитель или HR подтверждает нового пользователя | Есть владелец заявки, ФИО, подразделение, должность, набор доступов |
Нормализация | Данные приводятся к единому формату | Логин, UPN, OU, displayName, группы, срок действия |
Предпросмотр | Скрипт показывает, что будет создано | Нет дублей, логины валидны, OU и группы существуют |
Создание | Пользователь создаётся в AD | Пароль временный, смена пароля при первом входе включена |
Аудит | Результат пишется в лог | Понятно, кто создан, кто пропущен и где ошибка |
В первой версии этой заметки у меня была рабочая, но слишком доверчивая схема: Excel, CSV, VBA-транслитерация и пароль прямо в файле. Это была полезная заготовка, но слишком рискованная для процесса, где есть персональные данные и доступы. Ниже оставляю тот же подход, но в нормальном виде: с dry-run, проверками, логом и без хранения паролей в CSV.
Шаг 1. Утвердить стандарт учётной записи
Сначала надо договориться не о скрипте, а о правилах. Иначе автоматизация просто быстрее размножит бардак.
Минимальный регламент:
Поле | Пример | Правило |
|---|---|---|
|
| До 20 символов, только разрешённые символы, без ручных исключений |
|
| Формируется из логина и утверждённого suffix |
|
| Единый порядок ФИО |
|
| Выбирается по подразделению или типу сотрудника |
Группы |
| Назначаются по роли, а не «как у Петрова» |
Пароль | генерируется скриптом | Не хранится в таблице, меняется при первом входе |
Главное правило: логин должен быть детерминированным. Если сегодня у вас ivanov, завтра i.ivanov, а послезавтра ivanov_ii, никакой импорт не спасёт.
Шаг 2. Собрать данные в едином формате
Заявка может быть бумажной, в сервис-деске, в HR-системе или в почте. Важно другое: на входе должны быть одинаковые поля и понятный источник согласования.
В моём старом варианте дальше использовался Excel. Это не best practice, но честный инструмент второй линии: оператору видно строки, можно быстро найти ошибку, можно отдать шаблон смежному подразделению. Если процесс строится с нуля, лучше сразу брать данные из HR-системы или сервис-деска через API. Но для понятного примера оставлю таблицу.
Пример входной таблицы:
Name | SamAccountName | Department | Title | Groups |
|---|---|---|---|---|
Иванов Иван Иванович | i.ivanov | Бухгалтерия | экономист | RemoteApp-Users;Employees |
Петров Пётр Петрович | p.petrov | Склад | кладовщик | RemoteApp-Users;Warehouse |
Отдельная боль — транслитерация. В старой реализации у меня была VBA-функция TranslitText с массивами русских и латинских букв. Она работала, пока не встречались тонкие случаи с регистром, ё, составными фамилиями и локальными исключениями.
Лучше не держать самописную транслитерацию в Excel. Утвердите схему, например ICAO Doc 9303, и реализуйте её в одном месте: в скрипте импорта или отдельной библиотеке. Тогда таблица не становится вторым источником истины.
Шаг 3. Экспортировать CSV без секретов
CSV здесь нужен только как транспорт. В нём не должно быть паролей, лишних персональных данных, комментариев и временных полей.
Для русской локали важно явно указать разделитель. Excel часто сохраняет файл с ;, а не с запятой. Поэтому в скрипте ниже Import-Csv вызывается с -Delimiter ';'.
Плохой вариант:
Что лежит в CSV | Почему плохо |
|---|---|
Временный пароль | Файл уйдёт в почту, корзину, историю скачиваний или бэкап |
Неформализованные группы | Оператор руками выбирает доступы и ошибается |
Свободный комментарий | Скрипт начинает зависеть от человеческого текста |
Несколько вариантов логина | Повторный прогон создаёт дубли или падает |
Хороший вариант: в CSV только входные атрибуты, а всё вычисляемое создаётся скриптом.
Шаг 4. Прогнать dry-run
Перед созданием пользователей скрипт должен уметь запускаться в режиме предпросмотра. Это самый дешёвый способ поймать ошибки до того, как они попадут в домен.
Что проверяем до записи в AD:
Проверка | Зачем |
|---|---|
Пустые обязательные поля | Не создавать «безымянные» учётки |
Формат | Не пропустить мусор и слишком длинные логины |
Существование пользователя | Повторный запуск не должен падать на дублях |
Существование OU | Ошибка в DN не должна создавать пользователей не там |
Существование групп | Доступы должны назначаться предсказуемо |
Кодировка и разделитель CSV | Русские ФИО не должны превращаться в кашу |
Шаг 5. Импортировать пользователей
Ниже цельный пример. Его всё равно надо адаптировать под свой домен, правила именования, OU и группы, но базовая логика правильная: param() в начале, idempotency, -ErrorAction Stop, dry-run, лог и временный пароль без записи в CSV.
param( [string]$CsvPath = ".\users.csv", [string]$Server = "dc01.example.local", [string]$TargetOU = "OU=Users,OU=Company,DC=example,DC=local", [string]$DefaultUpnSuffix = "example.local", [switch]$DryRun ) Import-Module ActiveDirectory -ErrorAction Stop function New-RandomPassword { param([int]$Length = 18) $chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%*-_' $bytes = [byte[]]::new($Length) [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] }) } function Test-SamAccountName { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return $false } if ($Value.Length -gt 20) { return $false } return $Value -match '^[a-z0-9._-]+$' } function Write-ImportLog { param( [string]$Path, [string]$Level, [string]$Message ) $line = '{0};{1};{2}' -f (Get-Date -Format 's'), $Level, $Message $line | Tee-Object -FilePath $Path -Append } $log = ".\import_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" $rows = Import-Csv -Path $CsvPath -Delimiter ';' -Encoding UTF8 foreach ($row in $rows) { $sam = ([string]$row.SamAccountName).Trim().ToLowerInvariant() $name = ([string]$row.Name).Trim() $groupsRaw = [string]$row.Groups $groups = @($groupsRaw -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) if (-not (Test-SamAccountName -Value $sam)) { Write-ImportLog -Path $log -Level 'SKIP' -Message "Некорректный sAMAccountName: '$sam'" continue } if ([string]::IsNullOrWhiteSpace($name)) { Write-ImportLog -Path $log -Level 'SKIP' -Message "Пустое имя для '$sam'" continue } try { $existingUser = Get-ADUser -Server $Server -Filter "SamAccountName -eq '$sam'" -ErrorAction Stop if ($existingUser) { Write-ImportLog -Path $log -Level 'SKIP' -Message "Пользователь '$sam' уже существует" continue } Get-ADOrganizationalUnit -Server $Server -Identity $TargetOU -ErrorAction Stop | Out-Null foreach ($group in $groups) { Get-ADGroup -Server $Server -Identity $group -ErrorAction Stop | Out-Null } if ($DryRun) { Write-ImportLog -Path $log -Level 'DRY-RUN' -Message "Будет создан '$sam' ($name), группы: $($groups -join ', ')" continue } $password = New-RandomPassword $securePassword = ConvertTo-SecureString $password -AsPlainText -Force New-ADUser -Server $Server ` -Name $name ` -DisplayName $name ` -SamAccountName $sam ` -UserPrincipalName "$sam@$DefaultUpnSuffix" ` -Path $TargetOU ` -AccountPassword $securePassword ` -ChangePasswordAtLogon $true ` -Enabled $true ` -ErrorAction Stop foreach ($group in $groups) { Add-ADGroupMember -Server $Server -Identity $group -Members $sam -ErrorAction Stop } Write-ImportLog -Path $log -Level 'OK' -Message "Создан '$sam'" # В бою замените вывод пароля на защищённый канал выдачи: # сервис-деск, секрет-хранилище, SMS-шлюз или одноразовую выдачу оператором. Write-Host "Создан $sam. Временный пароль: $password" -ForegroundColor Green } catch { Write-ImportLog -Path $log -Level 'ERROR' -Message "'$sam': $($_.Exception.Message)" } }
Что принципиально изменилось по сравнению со старым вариантом:
Было | Стало |
|---|---|
Пароль лежал в CSV | Пароль генерируется при создании и не пишется в файл |
Скрипт был набором фрагментов | Есть цельный проход с параметрами и логом |
Ошибка могла оборвать импорт | Ошибка по одной строке не роняет весь прогон |
Повторный запуск создавал риск дублей | Существующие пользователи пропускаются |
Не было предпросмотра |
|
Проверки были в голове оператора | Формат логина, OU и группы проверяются скриптом |
Откат при необходимости делается по логу. В реальной среде я бы сначала отключал созданные учётки, а удалял только после проверки:
Select-String -Path .\import_*.log -Pattern ';OK;Создан ''(.+?)''' | ForEach-Object { $_.Matches.Groups[1].Value } | ForEach-Object { Disable-ADAccount -Server "dc01.example.local" -Identity $_ -ErrorAction Stop }
Взгляд из 2026
Если у вас классический on-premises Active Directory, подход выше всё ещё живой. New-ADUser в модуле ActiveDirectory никуда не делся, а массовый импорт через Import-Csv остаётся понятным и ремонтопригодным способом автоматизации.
Но контекст сильно изменился.
Во-первых, всё чаще каталог гибридный: учётная запись создаётся в локальном AD, а затем синхронизируется в Microsoft Entra ID через Microsoft Entra Connect Sync или Cloud Sync. В такой схеме локальный AD остаётся источником истины, поэтому порядок, idempotency и аудит важны ещё сильнее: ошибка уедет не только в домен, но и в облако.
Во-вторых, если пользователь создаётся сразу в облаке, ориентироваться надо уже не на старые модули AzureAD и MSOnline, а на Microsoft Graph PowerShell SDK. Microsoft объявил их устаревшими и перевёл сценарии управления пользователями на Graph.
Минимальный пример для облачного пользователя выглядит так:
Connect-MgGraph -Scopes "User.ReadWrite.All" $passwordProfile = @{ Password = (New-RandomPassword) ForceChangePasswordNextSignIn = $true } New-MgUser ` -DisplayName "Иван Иванов" ` -UserPrincipalName "ivanov@example.com" ` -MailNickname "ivanov" ` -AccountEnabled:$true ` -PasswordProfile $passwordProfile
Логика остаётся той же: сначала нормализация и dry-run, потом проверка дублей, потом создание, потом аудит. Меняются только API и права доступа.
Что я бы точно не оставлял в процессе в 2026 году:
Старый компромисс | Почему убрать | Чем заменить |
|---|---|---|
Пароли в CSV | Утечка через почту, корзину, бэкапы и сетевые папки | Генерация при создании и одноразовая защищённая выдача |
Самописная VBA-транслитерация | Сложно тестировать и поддерживать | Утверждённая схема транслитерации и тесты |
Ручной выбор групп | Ошибки доступа | Ролевая модель, шаблоны групп, согласование владельцем ресурса |
Запуск без dry-run | Ошибки сразу попадают в каталог | Предпросмотр, журнал, откат |
Один общий админ-аккаунт | Непонятно, кто что сделал | Именные админские учётки, JIT/JEA, аудит |
Итог
Хорошая автоматизация AD — это не «магический скрипт», а аккуратный конвейер. На входе у него нормализованные данные, внутри — проверки и идемпотентность, на выходе — понятный лог и возможность отката.
Такой подход не заменяет полноценный IDM/IAM, но снимает самую болезненную ручную работу и не создаёт новых проблем с безопасностью. Для второй линии поддержки это часто ровно тот уровень автоматизации, который быстро окупается.
