Добрый день. Хочу описать практическое решение одной скучной, но очень живой задачи второй линии техподдержки: массовое заведение пользователей в Active Directory.

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

Задача была не написать «идеальную IDM-систему», а быстро и предсказуемо снять ручную рутину: взять утверждённую заявку, привести данные к единому виду, создать учётную запись, добавить нужные группы и оставить понятный след для разбора ошибок.

Что автоматизируем

Сам процесс выглядит просто, но именно простые процессы чаще всего ломаются на мелочах.

Схема массового заведения пользователей в AD
Схема массового заведения пользователей в AD

Этап

Что происходит

Что должно быть проверено

Заявка

Руководитель или HR подтверждает нового пользователя

Есть владелец заявки, ФИО, подразделение, должность, набор доступов

Нормализация

Данные приводятся к единому формату

Логин, UPN, OU, displayName, группы, срок действия

Предпросмотр

Скрипт показывает, что будет создано

Нет дублей, логины валидны, OU и группы существуют

Создание

Пользователь создаётся в AD

Пароль временный, смена пароля при первом входе включена

Аудит

Результат пишется в лог

Понятно, кто создан, кто пропущен и где ошибка

В первой версии этой заметки у меня была рабочая, но слишком доверчивая схема: Excel, CSV, VBA-транслитерация и пароль прямо в файле. Это была полезная заготовка, но слишком рискованная для процесса, где есть персональные данные и доступы. Ниже оставляю тот же подход, но в нормальном виде: с dry-run, проверками, логом и без хранения паролей в CSV.

Шаг 1. Утвердить стандарт учётной записи

Сначала надо договориться не о скрипте, а о правилах. Иначе автоматизация просто быстрее размножит бардак.

Минимальный регламент:

Поле

Пример

Правило

sAMAccountName

i.ivanov

До 20 символов, только разрешённые символы, без ручных исключений

UserPrincipalName

i.ivanov@example.local

Формируется из логина и утверждённого suffix

displayName

Иванов Иван Иванович

Единый порядок ФИО

OU

OU=Users,OU=Company,DC=example,DC=local

Выбирается по подразделению или типу сотрудника

Группы

RemoteApp-Users, Employees

Назначаются по роли, а не «как у Петрова»

Пароль

генерируется скриптом

Не хранится в таблице, меняется при первом входе

Главное правило: логин должен быть детерминированным. Если сегодня у вас 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:

Проверка

Зачем

Пустые обязательные поля

Не создавать «безымянные» учётки

Формат sAMAccountName

Не пропустить мусор и слишком длинные логины

Существование пользователя

Повторный запуск не должен падать на дублях

Существование 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

Пароль генерируется при создании и не пишется в файл

Скрипт был набором фрагментов

Есть цельный проход с параметрами и логом

Ошибка могла оборвать импорт

Ошибка по одной строке не роняет весь прогон

Повторный запуск создавал риск дублей

Существующие пользователи пропускаются

Не было предпросмотра

-DryRun показывает будущие изменения

Проверки были в голове оператора

Формат логина, 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, но снимает самую болезненную ручную работу и не создаёт новых проблем с безопасностью. Для второй линии поддержки это часто ровно тот уровень автоматизации, который быстро окупается.

Ссылки