Guides+50 XP

Active Directory onboarding and offboarding with PowerShell: the script I use at work

How I automate Active Directory joiners and leavers with PowerShell: usernames, temporary passwords, groups, home folders and an audit log.

IvánPublished on 7 minLeer en español

Creating a user in Active Directory by hand is ten clicks per account, and every one of them is a chance to get it wrong: the wrong OU, a forgotten group, a home folder with no permissions. Leavers are worse, because when one is forgotten nobody complains, and the account of someone who’s gone stays active for months.

At work (public sector) we’ve solved this with a few PowerShell scripts and a simple window on top. Here’s how they’re built, so you can make your own.

Everything here is generic: no server names, nothing about the organisation. Domains, paths and groups are examples.

1. What onboarding does

The technician fills in first name, surname and department. The rest is optional: email, phone, description and expiry date (for interns or fixed-term contracts). On “Create user”, the script:

  1. Works out the username.
  2. Generates a temporary password the user must change at first sign-in.
  3. Creates the account in the department’s OU and adds it to its groups.
  4. Creates the home folder with the right permissions.
  5. Emails IT and, if requested, sends the user a welcome message.
  6. Writes it to the audit log.
  7. Asks GLPI to sync users from the directory right away.

The window and the command line call the same script. The window just collects the data:

.\AD-Alta.ps1 -Nombre "Lucía" -Apellidos "de la Peña Ruiz" -Departamento "Administracion"

2. The username: accents, ñ and duplicates

This is where half the problems live. Our rule is first initial plus first surname, lowercase, no odd characters. Three things to think about:

  • Accents and ñ: “Peña” has to become pena, not pea or pe?a.
  • Compound surnames: in “de la Peña”, the first surname is not “de”.
  • Duplicates: if lpena already exists, the new account becomes lpena2.
function ConvertTo-Ascii([string]$Texto) {
    # Split each letter from its accent (n + ~) and keep only the letter
    $normal = $Texto.Normalize([Text.NormalizationForm]::FormD)
    $sb = New-Object Text.StringBuilder
    foreach ($c in $normal.ToCharArray()) {
        if ([Globalization.CharUnicodeInfo]::GetUnicodeCategory($c) -ne 'NonSpacingMark') {
            [void]$sb.Append($c)
        }
    }
    $sb.ToString() -replace '[^a-zA-Z0-9]', ''
}

function Get-SamAccountName($Nombre, $Apellidos) {
    $particulas = 'de', 'del', 'la', 'las', 'los', 'y'
    $ape1 = ($Apellidos -split '\s+' | Where-Object { $_ -notin $particulas })[0]
    $base = (ConvertTo-Ascii ($Nombre.Substring(0, 1) + $ape1)).ToLower()
    $base = $base.Substring(0, [Math]::Min(18, $base.Length))   # the limit is 20
    $sam = $base; $n = 1
    while (Get-ADUser -Filter "SamAccountName -eq '$sam'") { $n++; $sam = "$base$n" }
    $sam
}

3. Temporary password and account creation

The password comes from a cryptographic generator, not Get-Random. I also leave out characters that get confused when read aloud (l, I, O, 0):

function New-TempPassword {
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
    $bytes = New-Object byte[] 14
    [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
    $pw = -join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
    "$pw#$($bytes[0] % 10)"   # always includes a symbol and a digit
}

Then the account gets created. Splatting (@params) lets you add the optional fields only when they’re filled in:

$sam = Get-SamAccountName $Nombre $Apellidos
$pw  = New-TempPassword
$ou  = "OU=$Departamento,OU=Usuarios,DC=your-domain,DC=local"

$params = @{
    Name                  = "$Nombre $Apellidos"
    GivenName             = $Nombre
    Surname               = $Apellidos
    SamAccountName        = $sam
    UserPrincipalName     = "$sam@your-domain.com"
    Path                  = $ou
    Department            = $Departamento
    AccountPassword       = ConvertTo-SecureString $pw -AsPlainText -Force
    ChangePasswordAtLogon = $true
    Enabled               = $true
}
if ($Email)  { $params.EmailAddress = $Email }
if ($Expira) { $params.AccountExpirationDate = $Expira }
New-ADUser @params

'GRP-Todos', "GRP-$Departamento" | ForEach-Object {
    Add-ADGroupMember -Identity $_ -Members $sam
}

Groups always follow the same pattern: one shared by everyone and one per department. That way the script doesn’t need a separate list: the OU and group structure is the configuration. If a new department appears tomorrow, you just create its OU and its group.

4. The home folder (and the replication gotcha)

$ruta = "\\file-server\USUARIOS$\$sam"
New-Item -ItemType Directory -Path $ruta | Out-Null

$sid   = (Get-ADUser $sam).SID
$acl   = Get-Acl $ruta
$regla = New-Object Security.AccessControl.FileSystemAccessRule(
    $sid, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($regla)
Set-Acl -Path $ruta -AclObject $acl

Notice the rule uses the SID, not YOUR-DOMAIN\user. With several domain controllers, the new account may not have reached the DC the file server asks yet. If you use the name, you’ll get “Some or all identity references could not be translated”, and only sometimes, which is the worst kind of bug. With the SID there’s nothing to translate.

5. Audit log

Every onboarding, offboarding and deletion appends a line to a CSV: when, who, from which computer, what action and on which user.

function Write-Auditoria($Accion, $Usuario, $Detalle) {
    [pscustomobject]@{
        Fecha   = Get-Date -Format 's'
        Tecnico = $env:USERNAME
        Equipo  = $env:COMPUTERNAME
        Accion  = $Accion
        Usuario = $Usuario
        Detalle = $Detalle
    } | Export-Csv -Path $LogAuditoria -Append -NoTypeInformation -Encoding UTF8
}

Write-Auditoria 'Alta' $sam "OU=$Departamento; grupos=GRP-Todos,GRP-$Departamento"

One rule worth remembering: the log says a password was generated, never what it is. The log is kept for years and more people can read it. If someone needs the temporary password, it goes through the channel you use to hand it over, and nowhere else.

If you use GLPI with directory users, you can trigger the sync over SSH at the end, so the person already exists in GLPI to get tickets or assigned devices:

ssh admin@glpi.your-domain.local "cd /var/www/glpi && sudo -u www-data php bin/console ldap:synchronize_users"

If you’re interested in the GLPI side, I wrote about wiring it to Wazuh in Wazuh + GLPI: automatic tickets and false positives.

6. Offboarding: disable, set aside, delete later

Offboarding deletes nothing on the spot. The account is disabled, its groups are recorded (in case the person returns, or you need to check what they had) and it’s moved to a leavers OU:

$u = Get-ADUser $sam -Properties MemberOf
Disable-ADAccount -Identity $u
Write-Auditoria 'Baja' $sam ("grupos=" + (($u.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace 'CN=' }) -join ','))
$u.MemberOf | ForEach-Object { Remove-ADGroupMember -Identity $_ -Members $u -Confirm:$false }
Set-ADUser $u -Description "Baja $(Get-Date -Format yyyy-MM-dd)"
Move-ADObject -Identity $u -TargetPath 'OU=Bajas,DC=your-domain,DC=local'

Once a month, a scheduled task goes through that OU and deletes accounts that have been disabled for more than 180 days. The date is read from the description rather than whenChanged, which changes with any modification to the account:

$limite = (Get-Date).AddDays(-180)
Get-ADUser -SearchBase 'OU=Bajas,DC=your-domain,DC=local' -Filter * -Properties Description |
    Where-Object { $_.Description -match '^Baja (\d{4}-\d{2}-\d{2})' -and [datetime]$Matches[1] -lt $limite } |
    ForEach-Object {
        Write-Auditoria 'Borrado' $_.SamAccountName 'Retencion 180 dias'
        Remove-ADUser -Identity $_ -Confirm:$false -WhatIf   # drop -WhatIf once you trust it
    }

7. The weekly report

Another scheduled task emails IT a summary every Monday. It has the week’s joiners and leavers (taken from the audit CSV) and two lists that prevent nasty surprises:

# Accounts expiring in the next 30 days (interns, contracts)
Search-ADAccount -AccountExpiring -UsersOnly -TimeSpan 30.00:00:00

# Enabled accounts with no sign-in for 90 days
Search-ADAccount -AccountInactive -UsersOnly -TimeSpan 90.00:00:00 |
    Where-Object Enabled

The second list is the one that catches leavers nobody told IT about, and every organisation has them.

8. PowerShell 5.1 details

The scripts have to run on the PowerShell that ships with Windows (5.1). There’s a trap there: if the .ps1 is UTF-8 without a BOM, PowerShell 5.1 reads it as ANSI and any accented characters in the code come out garbled. That’s why our scripts are pure ASCII: no accents in comments or messages. The other option is to save them as UTF-8 with a BOM.

A small installer puts a shortcut on the technician’s desktop that opens the window:

$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut("$env:USERPROFILE\Desktop\Gestion AD.lnk")
$lnk.TargetPath = 'powershell.exe'
$lnk.Arguments  = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "C:\Scripts\AD\AD-Gui.ps1"'
$lnk.Save()

What not to do

  • Hardcode passwords in the code. Not the mail account’s, not anyone’s. Use the Microsoft.PowerShell.SecretManagement module, or at least Export-Clixml, which encrypts with DPAPI for that user on that machine.
  • Write the temporary password to the log, a ticket or the team chat.
  • Delete accounts at offboarding time. Disabling can be undone; deleting can’t.
  • Assume email notifications work without testing them. Do a test onboarding every time the mail server changes. The day a notification goes missing will be a leaver’s.
  • Use Send-MailMessage for anything new. Microsoft marks it as obsolete. If you do use it, point it at an internal relay, not a personal mailbox.

Summary

  1. One script for the window and the console: usernames without accents or duplicates, a cryptographic temporary password and the account created with @params.
  2. OUs and groups that follow a fixed pattern, so the directory structure is the configuration.
  3. Home folder permissions by SID, so replication doesn’t matter.
  4. A CSV audit log, with no passwords.
  5. Offboarding that disables and sets aside, deletion after 180 days, and a weekly report that catches forgotten accounts.

Automating onboarding saves time, but what really makes the difference is automating offboarding: it’s the part that’s always left half-done when you do it by hand.

Mini quiz

Did it stick?

Three quick questions. Each right answer is worth 10 XP.

  1. Why should you grant home folder permissions using the user's SID rather than their name?
  2. What's the first thing an offboarding should do?
  3. What must never appear in the audit log?
  • #active-directory
  • #powershell
  • #windows-server
  • #sysadmin
  • #glpi
Esc