Привет, Хабр! Меня зовут Иван Мороз, я системный администратор в BPMSoft. В нашей компании существовала проблема с контролем прав локального администратора на сотнях корпоративных ноутбуков. Ручной учет через Excel или стандартные GPO оказалось неэффективным, а ошибки могли приводить к проблемам с безопасностью и операционным рискам.

В этой статье я расскажу, как автоматизировал выдачу и изъятие прав локального админа с помощью PowerShell и шедулера, как строилась концепция решения, какие трудности возникли и как их удалось обойти. Я покажу конкретные блоки кода и дам практические советы для внедрения подобных процессов в крупных корпоративных средах.

Задача

В какой-то момент перед отделами ИТ \ ИБ встала комплексная задача — систематизировать процесс выдачи, изъятия и контроля прав локального администратора на корпоративных ноутбуках.

  1. Понять, кто из сотрудников имеет права сейчас.

  2. Забрать права у тех, кому они не нужны.

  3. Автоматизировать процесс так, чтобы выдача и изъятие происходили быстро, контролируемо и без подключения к ноутбуку. Желательно в пару кликов.

  4. Иметь возможность убедиться, что наши действия привели к ожидаемому результату: автоматика отработала корректно, сотрудник, получив права, не выдал их кому-то ещё; если права были изъяты, они действительно изъяты.

Известные решения, и почему они не подошли

  1. Самый простой и неэффективный способ решения задачи — выдавать права “руками” и вести реестр в Excel. Это плохо примерно всем: сплошной “человеческий фактор” и никакого контроля. Вы один раз выдали права и не имеете представления, что происходит с ноутбуком дальше.

  2. Делегирование прав через Group Policy Management (GPO): Computer Configuration → Preferences → Control Panel Settings → Local Users and Groups → Item-level Targeting.
    Этот способ уже лучше — групповая политика при каждом применении будет выдавать права, проверять, что они уже есть, и вычищать из группы Administrators всех лишних. 

Из минусов

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

  2. Если на конкретном ноутбуке что-то пошло не так (по любой причине), вы об этом не узнаете. Оснастка GPO не дает отчета об успешном применении политики. 

  3. Нельзя сделать оперативно выгрузку и посмотреть, кто на каком ноутбуке имеет повышенные привилегии, а этот вопрос рано или поздно будет задан.

Решение

Концепция — нужно обеспечить приведение каждого ноутбука к следующему состоянию:

Права администратора имеют:

  1. Встроенная учетная запись администратора

  2. Группа Domain Admins

  3. “Легитимный админ” — сотрудник, которому согласованы права локального администратора (если такой есть)

  4. У всех остальных их нужно отобрать.

    Выдача и изъятия прав происходит посредством PowerShell — скрипта, который запускается на каждом ноутбуке через мгновенный шедулер (Immediate Task) от имени системы (NT AUTHORITY\System) при каждом обновлении GPO в фоновом режиме. Дальше разберем подробнее составляющие процесса.

Планировщик задач:

  • Вне зависимости от регистрации пользователя

  • От имени системной учетной записи

  • С повышенными привилегиями

  • При выполнении скрипта — игнорировать ExecutionPolicy

Может возникнуть вопрос: почему процесс запускается через Планировщик задач, а не через Startup \ Logon скрипт? Кажется, что второе проще и интуитивно понятнее.
Ответ: потому что это работает только в “тепличных условиях”, когда машина имеет стабильное соединение с контроллером домена (желательно по кабелю), в том числе при включении компьютера \ входе пользователя в сессию. Если у вас половина компании — удаленщики, которые запускают VPN как придется, это не будет работать стабильно.

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

Сетевая папка для хранения скрипта и логов

Я решил хранить скрипт в сетевой шаре и, поскольку он должен выполняться от имени SYSTEM, компьютеры в AD должны иметь к ней доступ. Я выдавал доступ сразу на группу Domain Computers. В шару, где лежит скрипт — только на чтение. Но в ней ещё есть папка для хранения логов — и уже туда запись + чтение.

Active Directory

Выдавая или забирая права админа, система должна понимать \ ориентироваться, откуда-то брать данные “Кому выдать — Где выдать”. Для этих целей я решил использовать Active Directory, записывая в атрибут ManagedBy каждого компьютера - учетную запись пользователя, который должен иметь права админа на этом компьютере. Поэтому, если нужно выдать права, это делается через оснастку ADUC в пару кликов:

PowerShell-скрипт

У скрипта есть несколько логических блоков:

  1. Проверка и установка модуля Active Directory для Powershell, если его нет

  2. Определение переменных\монолитов

  3. Проверка, должен ли кто-то иметь права локального админа на этом ПК

    • Если никто не должен — переходим к следующему блоку

    • Если должен — проверить, имеет ли он уже права

      • Если имеет — все хорошо, ничего делать не нужно

      • Если не имеет — выдаем, оповещаем пользователя, фиксируем это для лога

  4. Проверка, имеет ли права локального админа кто-то лишний 

    • Смотрим список всех пользователей в группе администраторов

    • Проверяем каждого

      • Если это легитимный сотрудник или дефолтная учетка админа (SID = *500) — оставляем

      • Если это кто-то иной — удаляем его из группы администраторов, оповещаем пользователя, фиксируем это для лога

    • Смотрим, есть ли группы безопасности, отличные от Domain Admins. Если есть — удаляем, оповещаем пользователя, фиксируем это для лога

  5. Логирование

    • Если в процессе отработки скрипта не было произведено значимых действий (кому-то выдали, у кого-то забрали) и не было ошибок, лог писать не нужно

    • Если какие-то сущностные изменения были или были ошибки, делаем запись лога

      Разберем подробнее эти блоки

Блок 1. Проверка и установка модуля Active Directory 

#Проверяем, что компонент RSAT .ActiveDirectory установлен. Если не установлен — устанавливаем
$folder = "\\share\folder"
$status_AD=(Get-WindowsCapability -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0 -Online).state
if ($status_AD -eq "NotPresent") {Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0 -LimitAccess -Source $folder}

Без модуля ActiveDirectory не получится выполнить запрос к AD и проверить наличие “легитимного” админа. Поэтому ставим, если ещё не стоит. Я ставлю из локальной шары через опцию LimitAccess.

Блок 2. Определяем переменные

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

#кто легитимный локальный админ
try { $check_local_admin_rights = (Get-ADComputer -Identity $(hostname) -Properties ManagedBy -ErrorAction Stop).ManagedBy} 

#остановка выполнения скрипта, если не удалось определить
catch {throw "Не удалось определить легитимного админа. Остановка скрипта"} 

#определяем название группы доменных администраторов
try { $domain_admins = (Get-ADGroup -filter * -ErrorAction Stop | Where-Object {$_.SID -like '*-512'}).name} 
#остановка выполнения скрипта, если не удалось определить
catch {throw "Не удалось определить группу Администраторов Домена. Остановка скрипта"} 

#выясняем название учетки локального админа (у неё идентификатор заканчивается на 500)
$build_admin = (Get-LocalUser | Where-Object {$_.SID -like '*500'}).name 

#определяем название группы локальных администраторов
$build_admin_group = (Get-LocalGroup | Where-Object {$_.SID -like '*544'}).name 

#куда пишутся логи
$error_logs = "\\share\folder\file.txt" 

#определяем доменный префикс
$domain = (Get-ADDomain -Current LocalComputer).name

Важные детали: 

  • Запросы к AD нужно обрабатывать через try \ catch с обязательной остановкой скрипта в случае ошибки. Если в этом моменте произойдет ошибка по любой причине (DC был перегружен и не смог ответить, сбой в сети и т.д.), в переменную ничего не запишется. И скрипт продолжит выполнение работы, считая, что на этом компьютере никто не должен иметь права админа. Так можно забрать у того, кто должен их иметь. Потом придется краснеть.

  • Название учетной записи встроенного админа или группы надо определять по SID, поскольку оно может меняться в зависимости от локализации (Администратор \ Administrator). SID дефолтных учетных записей и групп известны — это т.н. “well-known” идентификаторы безопасности:

    https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids

Блок 3. Проверка, должен ли кто-то иметь права локального админа на этом пк

Проверяем, должен ли какой-то сотрудник (его доменная учетка) иметь права админа на данном ПК, если должен - имеет ли, если имеет - отлично, если нет - выдаем, оповещаем пользователя, логгирование

#проверяем должен ли кто-то иметь права локального админа на данном компе
if ($check_local_admin_rights -eq $null) {$exit_legal = $null}  #если никто не должен - значит можно завершать этот блок скрипта

else #если должен - продолжаем выполнение

    {
      #смотрим, кто именно должен иметь права админа
      try { $legal_local_admin = (Get-ADUser -Filter * -Properties DistinguishedName -ErrorAction Stop | Where-Object {$_.DistinguishedName -like $check_local_admin_rights}).SamAccountName} 
      
      #остановка выполнения скрипта, если не удалось определить
      catch {throw "Не удалось определить легитимного админа. Остановка скрипта."} 
      
      #получаем список доменных групп и пользователей, которые находятся в группе локальных админов
      $all_local_admins = Get-LocalGroupMember -Group $build_admin_group | Where-Object {$_.PrincipalSource -EQ 'ActiveDirectory'}      

      foreach($user in $all_local_admins)
        
        {
            #выбираем из массива только пользователей и проверяем каждого
            if (($user.ObjectClass -eq "User") -or ($user.ObjectClass -eq "Пользователь")) 
            
            {   
                #проверяем, есть ли среди членов группы локальных админов - доменная учетная запись сотрудника
                if ($user.Name -like "*$legal_local_admin") {$i+=1} 
            }        
        }

      #если не имеет - выдаем, логируем это и оповещаем пользователя
      if ($i -eq $null) {

        try {

            Add-LocalGroupMember -Group $build_admin_group -Member "$domain\$legal_local_admin" -ErrorAction Stop
            #текст для лога            
        	$exit_legal = "Grant rights legitimate admin: $legal_local_admin"
            msg * "Доменному пользователю [$legal_local_admin] делегированы права локального администратора ��а этом ПК. Перелогиньтесь, чтобы они вступили в действие."
        }

        catch {Write-Error "Ошибка при выдаче прав локального админа"}
        
#дефолтное запись этого блока, которая будет записана в лог, если кого-то не удалят
        } else {$exit_legal = $null} #текст для лога остается пустой = значимых изменений не было
    } 

Важные детали:

  • К сожалению, в зависимости от локализации, значение ObjectClass может быть и “User”, и “Пользователь”. Я не нашел способа точно определять это значение, поэтому “прибил гвоздями” оба варианта.

  • Не забываем делать запись в переменную $exit_legal, которая потом понадобится для логирования.

Блок 4. Проверка, имеет ли права локального админа кто-то лишний \ нелегитимный

#получаем список групп и пользователей, которые находятся в группе локальных админов
$all_local_admins = Get-LocalGroupMember -Group $build_admin_group 

    foreach($user in $all_local_admins)
        { 
            #выбираем из массива ЛОКАЛЬНЫХ пользователей
            if ((($user.ObjectClass -eq "User") -or ($user.ObjectClass -eq "Пользователь")) -and ($user.PrincipalSource -eq "Local"))  
                { $delete_local_user = $user.Name.split('\')[1] #обрезаем доменный префикс

                    #если ты учетка локального админа, тебя оставляем
                    if ($build_admin -eq $delete_local_user) {Write-Host "Оставляем: $build_admin"}  
                    
                    #если ты кто-то иной, тебя удаляем из группы, логируем это и оповещаем пользователя
                    else {

                        try {
                            Remove-LocalGroupMember -Group $build_admin_group -Member $delete_local_user -ErrorAction Stop 
                            
                            #текст для лога            
                        	$exit_no_legal += " Deleted no-legitimate admin (local): $delete_local_user" 
                            msg * "У локального пользователя [$delete_local_user] больше нет прав локального администратора на этом ПК."
                            }

                        catch {Write-Error "Ошибка при изъятии прав у локального пользователя"} 
                         } 
                }

            #выбираем из массива ДОМЕННЫХ пользователей
            if ((($user.ObjectClass -eq "User") -or ($user.ObjectClass -eq "Пользователь")) -and ($user.PrincipalSource -eq "ActiveDirectory")) 
                
                { $delete_user = $user.Name.split('\')[1] #обрезаем доменный префикс

                    #если ты легитимный сотрудник, у которого должны быть права локального админа, тебя оставляем
                    if ($legal_local_admin -eq $delete_user) {Write-Host "Оставляем: $legal_local_admin"} 
                    
                    #если ты кто-то иной, тебя удаляем из группы, логируем это и оповещаем пользователя
                    else { 
                        try {
                            Remove-LocalGroupMember -Group $build_admin_group -Member "$domain\$delete_user" -ErrorAction Stop
                            #текст для лога
                         	$exit_no_legal += " Deleted no-legitimate admin: $delete_user"
                            msg * "У доменного пользователя [$delete_user] больше нет прав локального администратора на этом ПК."
                            }

                        catch {Write-Error "Ошибка при изъятии прав у доменного пользователя"}
                         } 
                }

            #выбираем из массива ГРУППЫ БЕЗОПАСНОСТИ    
            if (($user.ObjectClass -eq "Group") -or ($user.ObjectClass -eq "Группа")) 
                { $delete_group = $user.Name.split('\')[1] #обрезаем доменный префикс

                    #если ты группа Domain Admins, тебя оставляем
                    if ($delete_group -eq $domain_admins) {Write-Host "Оставляем: $delete_group"} 
                    
                    #если ты кто-то иной, тебя удаляем из группы, логируем это и оповещаем пользователя
                    else {
                        try {
                            Remove-LocalGroupMember -Group $build_admin_group -Member "$domain\$delete_group" -ErrorAction Stop
                            #текст для лога
                    		$exit_no_legal += "Deleted no-legitimate group: $delete_group"
                            msg * "Доменная группа [$delete_group] больше не имеет права локального администратора на этом ПК."
                            }

                        catch {Write-Error "Ошибка при изъятии прав у группы безопасности"}
                        }      
                }
          }
#дефолтное запись этого блока, которая будет записана в лог, если кого-то не удалят = сущностных операций не было
if ($exit_no_legal -eq $null) {$exit_no_legal = $null} 

Важные детали:

Локальных и доменных пользователей нужно проверять отдельно и удалять с префиксом (доменные) \ без префикса (локальные). Может возникнуть вопрос, зачем, если можно удалять доменных пользователей без доменного префикса. 

Например: Remove-LocalGroupMember -Group $build_admin_group -Member ivan.ivanov

И это действительно работает. До тех пор, пока на ноутбуке не будет создана локальная учетная запись с именем, полностью идентичным доменной.
Когда это произойдет, командлет Remove-LocalGroupMember будет пытаться удалить именно локальную. Может быть кейс, что вам надо выбить из группы локальных администраторов доменную учетку, а командлет будет пытаться выбить локальную и получать ошибку при каждой отработке скрипта. Вероятность этого кейса на грани погрешности, но я с ним столкнулся :-) 

Блок 5. Логирование

#проверяем, были ли выполнены какие-то сущностные действия
if ( ($exit_legal -eq $null) -and ($exit_no_legal -eq $null) -and (!$error) ) {Write-Host "Писать лог не нужно"} 
else 
  {
    #собираем данные в одну переменную
    $export = "$(Get-Date) | $(hostname) | $exit_legal | $exit_no_legal | Error: $error "
    $export | Add-Content -Path $error_logs  #делаем запись в лог
  }

Важные детали:

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

Я не хотел видеть в файле лога результат выполнения каждого скрипта на каждом ноутбуке, поэтому решил, что буду писать лог только если произошло что-то сущностное, например выдача прав, изъятие прав, любая ошибка, которая окажется в переменной $error

Структура лога следующая:
время и дата выполнения | хостнейм ПК | выдача прав | изъятие прав | ошибки 

Пример записей в файле лога:
03/04/2025 16:30:03 | WIN10-PC4 | Grant rights legitimate admin: Ivan.Ivanov |  | Error: 
04/22/2025 22:32:39 | WIN11-PC14 |  |  Deleted no-legitimate admin: Ivan.Ivanov Deleted no-legitimate group: ИТ-отдел | Error: 

Я решил выполнять логирование всех значимых событий в одной строке, которая пишется в самом конце скрипта. Но можно сразу выполнять запись в лог, в момент когда произошло “значимое действие”, не дожидаясь окончания скрипта.

Преимущество моего решения

  1. Простота для исполнителя. Выдача \ изъятие происходит без подключения к рабочей станции, через оснастку ADUC в несколько кликов на вкладке ManagedBy. В ActiveDirectory я указываю “нужное состояние”, а дальше групповые политики приводят реальность к этому состоянию при каждом обновлении.

  2. Отчетность. Путем выполнения простого скрипта я получаю выгрузку “Компьютер — Пользователь” и могу ответить на вопросы “Кто?”, “Где?”, “Сколько всего?”

    $Local_Admins = Get-ADComputer -Filter * -Properties managedBy | Where-Object {$_.managedBy -ne $null} | Select-Object @{n="Computer"; e={$_.name}}, @{n="Local Admin"; e={$_.managedBy.split(',')[0]}}
    $Local_Admins
    Write-Host "Всего: $($Local_Admins.Count)`r`n" -ForegroundColor Yellow
  3. Логирование. В файле лога я вижу все сущностные операции, которые выполняет политика и скрипт: кому выдала, у кого забрала. Вижу тех, у кого не должно было быть прав, но почему-то были. А когда права нужно выдать — могу отследить, что они действительно были выданы.

Траблшутинг

Насколько бы не была продумана система, иногда она дает сбой. Надо разбираться почему. В этой части не будет подробного разбора кейсов — я лишь покажу, каким должен быть вектор вашей мысли:

  1. Вы не просто так добавляли логирование в логику скрипта. Первым делом идем смотреть, что скрипт написал нам туда из переменной $error и интерпретируем увиденное

  2. Автоматизация работает через групповую политику. А к моему ноутбуку она применяется? Проверяем через командную строку с повышенными привилегиями и gpresult /r

  3. Политика точно применяется, но что она делает? И где логируется это “что”? Первое — создает задачу в планировщике, поэтому идем смотреть логи в Event Viewer - Application and Services Logs - Microsoft - Windows - Task Scheduler - Operational. Там вы должны увидеть, что система:

    • зарегистрировала задачу (Task registered)

    • задача затригерилась (Task triggered on scheduler)

    • создала процесс (Created Task Process)

    • запустила задачу (Task Started)

    • выполнила задачу с каким-то кодом (Action completed)

  4. Хорошо, планировщик запускал задачу, она что-то делала. Что?
    Запускала PowerShell-скрипт! А значит еще можно посмотреть логи в Event Viewer - Application and Services Logs - Windows PowerShell. Там можно увидеть, какой именно скрипт запускался и запускался ли.

  5. Видим, что запускался наш скрипт из сетевой шары — отлично! Но хорошо бы понять, что произошло в процессе выполнения. И чтобы понять это, нужно заблаговременно включить через GPO на ноутбуках расширенное логирование PowerShell, которое будет сохранять транскрипции, например в C:\Windows\Logs\Powershell

Существует официальный баг (https://github.com/PowerShell/PowerShell/issues/2996), из-за которого при выполнении скрипта вы будете получать ошибку Get-LocalGroupMember : Failed to compare two elements in the array на единичных машинах. Она вызвана пустыми SID в группе администраторов, которые остались после присоединения/выхода из домена.

Фиксится следующим образом:

#смотрим название баганной учетки
([ADSI]"WinNT://./Administrators").psbase.Invoke('Members') | % {([ADSI]$_).InvokeGet('AdsPath')}
#удаляем её
Remove-LocalGroupMember -group "administrators" -member ‘name’

Заключение

  1. Решение будет удобно для тех, кому нужно делегировать права локального админа сильно больше, чем на 10-20 рабочих станциях. 

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

  3. Выдачу прав через оснастку ADUC можно делегировать коллегам из ИБ \ Техподдержке, выдав права редактировать атрибут managedBy на уровне AD в конкретной OU. Ровно как и просмотр \ мониторинг логов — выдав права на чтение сетевой шары.

  4. О выдаче \ изъятии прав лучше оповещать пользователя посредством утилиты msg, чтобы этот процесс проходил для него прозрачно и вызывал меньше вопросов:

  5. Отдавая выполнение процесса со сложной логикой PowerShell-скрипту, вы должны базово уметь работать с этим средством автоматизации, читать чужой код, уметь интерпретировать его ошибки.