Mass Edit ADSI Values - 夜莺博客

Mass Edit ADSI Values

Original article: Mass Edit ADSI Values — theDXT (Daniel Keer)

I got tired of having to manually edit ADSI values for more then one user so I wrote a PowerShell script to automate the process. I even went to the extent to include a display of the values that were changed at the end. The main purpose of this script was to edit the msExchHideFromAddressLists value when the user is placed in a specific OU. I ended up deciding to make my script in a way so that I could just change 3 values and it will do all the work.

Here’s the script.

$value = "msExchHideFromAddressLists"
$ou = "OU=Disabled Users,OU=Contoso,DC=contoso,DC=local"
$yesno = $true

$search = Get-ADUser -filter * -searchbase $ou

Foreach ($U in $search) {
Set-ADUser $U -replace @{$value=$yesno}
}

Write-Output "Results"

Get-ADUser -filter {($value -eq $yesno)} -searchbase $ou -properties $value | Select-Object UserPrincipalName, $value

Just alter $value, $ou, and $yesno to suit your needs and the script does the rest.

Background: ADSI Edit does not scale

ADSI Edit is a fine tool for looking at the directory and a terrible one for changing it in bulk. Every attribute change is a manual sequence — open the connection, browse to the object, open the properties dialog, scroll through a flat alphabetical list of attributes, double-click, set the value, confirm. For two or three accounts that is tolerable. For the contents of an entire OU, at the moment a user is offboarded, it is neither fast nor auditable, and the manual repetition is exactly where mistakes happen.

What makes the scripted approach practical is that ADSI Edit is not doing anything special. The GUI writes plain LDAP attributes on the object, and Set-ADUser writes to the same attributes through the same directory. Everything you can see in the attribute list — department, targetAddress, extensionAttribute1, proxyAddresses, and Exchange's own attributes such as msExchHideFromAddressLists — is reachable from PowerShell with better logging and a dry-run mode.

The example attribute is worth understanding because it is a common offboarding task. msExchHideFromAddressLists is a boolean. Setting it to TRUE removes the mailbox from the Exchange global address list and from the offline address book, so a departed user stops appearing in the address book that Outlook caches locally. The organisational trick in the original script is using OU membership as the trigger: moving a user into “Disabled Users” is already the authoritative signal that they have left, so deriving the attribute change from the OU keeps the two facts from drifting apart. Nobody has to remember to run a second step, because the placement is the step.

The trade-off is that the trigger is now implicit and every object in that OU is in scope. A script built around a single organisation's structure will happily edit service accounts, shared mailboxes or a test user that happened to be parked in the same container. That risk is the reason the rest of this article is about scope, backup and verification rather than about the three lines that do the actual work.

Prerequisites

  • RSAT Active Directory PowerShell module. On a workstation:
    Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0
    Import-Module ActiveDirectory
    

    On a server: Install-WindowsFeature RSAT-AD-PowerShell.

  • Permission on the target objects. Being an Account Operator is usually not sufficient, because that role does not include write access to many attributes on user objects. A delegated right on the OU is the cleanest option; Domain Admin works but is broader than needed.
  • Line of sight to a writable domain controller, or an explicit -Server and -Credential if you are working across a trust.
  • A backup of the current values before the first write. Attribute changes replicate immediately and there is no undo, so the only rollback path is the one you build yourself.
  • Exchange schema extensions present if you intend to touch mail attributes. msExchHideFromAddressLists exists in the forest schema only when it has been prepared for Exchange, typically because on-premises Exchange was installed or a hybrid configuration with Exchange Online was set up. Confirm the attribute exists before you write a script around it.
  • An off-hours window for anything touching a large OU, since the writes are serialised against the directory and each one is a separate LDAP modify.

How the original script works

The ten lines are worth reading line by line, because the whole technique is in three of them.

$value = "msExchHideFromAddressLists" The LDAP attribute to change, held in a variable so the same script can be reused for a different attribute without editing the loop.
$ou = "OU=Disabled Users,..." The distinguished name that defines the scope. This must be a real DN, read bottom-up from the leaf: OU=Disabled Users,OU=Contoso,DC=contoso,DC=local.
$yesno = $true The new value, as a PowerShell boolean. The AD module translates a [bool] into the directory's TRUE / FALSE representation, which is why a real boolean and not the string 'true' is the correct input.
Get-ADUser -filter * -searchbase $ou Returns every user object under that DN. The default search scope is Subtree, so child OUs are included — a detail that has surprised plenty of people running this on a container with nested OUs.
Foreach ($U in $search) { Set-ADUser $U -replace @{$value=$yesno} } The core. Set-ADUser accepts an ADUser object directly as the identity. The -Replace parameter takes a hashtable where each key is an attribute name and each value is the new value; building the hashtable from variables keeps the loop generic. Crucially, -Replace touches only the attributes named in the hashtable and leaves everything else on the object alone.
Write-Output "Results" followed by the second Get-ADUser The reporting block. It re-queries the same scope with a filter for the new value and prints the account and the attribute, so the operator sees which objects ended up matching.

One fragility is worth correcting up front. The original reporting query uses a script block with variables in it:

Get-ADUser -filter {($value -eq $yesno)} -searchbase $ou -properties $value

The AD module sometimes evaluates those variables and sometimes does not, depending on the version and how the filter is parsed. The reliable form is a filter string with the value interpolated, and for a boolean attribute the literal needs to be written with a dollar sign inside quotes:

Get-ADUser -Filter "msExchHideFromAddressLists -eq '$true'" -SearchBase $ou -Properties msExchHideFromAddressLists

This is also where a backup strategy starts to pay off. Any change to an attribute that feeds Exchange or Entra ID synchronisation is best applied to a test user first, then to a small OU, and only then to the real thing.

Step by step, safely

Step 1 — define scope and count what you are about to touch

$value  = 'msExchHideFromAddressLists'
$ou     = 'OU=Disabled Users,OU=Contoso,DC=contoso,DC=local'
$newVal = $true

$targets = Get-ADUser -Filter * -SearchBase $ou -Properties $value, adminCount
$targets.Count
$targets | Select-Object SamAccountName, UserPrincipalName, adminCount, $value |
    Sort-Object SamAccountName | Format-Table -AutoSize

If the count is larger than you expected, do not proceed. Fix the scope first — that number is the only cheap warning you will get. Objects with adminCount = 1 are protected by AdminSDHolder and deserve a deliberate decision rather than inclusion by accident.

Step 2 — dry run with -WhatIf

Set-ADUser supports -WhatIf, which prints the intended operation without writing anything. This is the fastest way to confirm the loop hits the objects you think it does.

foreach ($U in $targets) {
    Set-ADUser -Identity $U -Replace @{ $value = $newVal } -WhatIf
}

Step 3 — back up the current values to CSV

$stamp  = Get-Date -Format 'yyyyMMdd-HHmmss'
$backup = "C:\Temp\adsi-backup-$stamp.csv"

$targets | Select-Object SamAccountName, DistinguishedName, adminCount, $value |
    Export-Csv -NoTypeInformation -Encoding UTF8 -Path $backup

$backup

Keep this file. It is both the audit record of what the values were and the input to the rollback in step 6.

Step 4 — make the change with per-object logging

$logFile = "C:\Temp\adsi-change-$stamp.csv"

$results = foreach ($U in $targets) {
    $before = $U.$value
    try {
        Set-ADUser -Identity $U -Replace @{ $value = $newVal } -ErrorAction Stop
        [pscustomobject]@{
            User   = $U.SamAccountName
            UPN    = $U.UserPrincipalName
            Before = $before
            After  = $newVal
            Status = 'OK'
        }
    } catch {
        [pscustomobject]@{
            User   = $U.SamAccountName
            UPN    = $U.UserPrincipalName
            Before = $before
            After  = $newVal
            Status = "FAILED: $($_.Exception.Message)"
        }
    }
}

$results | Export-Csv -NoTypeInformation -Encoding UTF8 -Path $logFile
$results | Group-Object Status | Select-Object Name, Count | Format-Table -AutoSize

Wrapping each object in its own try/catch is what makes this safe to run unattended. One protected object or permission problem no longer aborts the loop half way through, and the summary at the end tells you at a glance whether every write succeeded.

Step 5 — verify

Get-ADUser -Filter "msExchHideFromAddressLists -eq '$true'" -SearchBase $ou `
    -Properties msExchHideFromAddressLists |
    Select-Object UserPrincipalName, msExchHideFromAddressLists |
    Sort-Object UserPrincipalName | Format-Table -AutoSize

Compare the number of objects returned by the verification query against the number of OK rows in the log. They should match. If the verification query returns fewer, either those objects live in a child OU that the earlier query reached but this one does not, or a group policy or synchronisation tool has already reverted them.

Step 6 — roll back if it went wrong

$backup = 'C:\Temp\adsi-backup-20260914-101500.csv'

Import-Csv -Path $backup | ForEach-Object {
    $old = if ($_.msExchHideFromAddressLists -eq 'True') { $true } else { $false }
    Set-ADUser -Identity $_.SamAccountName `
        -Replace @{ msExchHideFromAddressLists = $old } -ErrorAction Continue
}

Import-Csv hands you strings, not booleans, so the value has to be converted back before it is written. Passing the string 'True' to an attribute that expects a boolean is one of the least helpful errors the AD module produces, because the message points at the cmdlet rather than at the type.

Other attributes you can drive the same way

msExchHideFromAddressLists Boolean — hide the mailbox from Exchange address lists.
extensionAttribute1 … 15 Free-text slots, typically written by HR or an identity tool. Single-valued strings.
department, title, company Plain strings; safe targets for bulk standardisation.
telephoneNumber, ipPhone Strings; watch the leading + and the ;ext= suffix convention.
targetAddress String — the forwarding address for a mail-enabled user, used in migration cutovers.
proxyAddresses Multi-valued. Use -Add and -Remove rather than -Replace, and remember that an uppercase SMTP: prefix denotes the primary address while lowercase smtp: is secondary.
msExchRecipientTypeDetails Integer bitmask. Only touch it if you understand the existing value; this is one of the places where a wrong number produces a mailbox that will not open.
userAccountControl Integer bitmask controlling enabled state, password requirements and more. Do not poke bits by hand — use Disable-ADAccount, Enable-ADAccount and Unlock-ADAccount.
info, description Free-text strings, commonly used to record the offboarding date.

Multi-valued attributes need the collection parameters rather than a plain replace:

Set-ADUser -Identity 'jdoe' -Add    @{ proxyAddresses = 'SMTP:jdoe@contoso.com' }
Set-ADUser -Identity 'jdoe' -Remove @{ proxyAddresses = 'smtp:jdoe@old.contoso.com' }

And a single pass can set several attributes, which is usually what you want because it is one LDAP write per object instead of two:

Set-ADUser -Identity 'jdoe' -Replace @{
    msExchHideFromAddressLists = $true
    extensionAttribute3        = 'OFFBOARD-2026-09'
    description                = 'Disabled 2026-09-14 - left company'
}

Common problems

“The term 'Get-ADUser' is not recognized.” RSAT is missing or the module is not imported. Install the capability and run Import-Module ActiveDirectory in the same session.

The script changes nothing, but reports no errors. The most common cause is a distinguished name that does not match what the server holds — a typo, an OU that was renamed, or a child OU whose placement in the DN is wrong. Query for the container as an object first and copy the DN from the output rather than typing it:

Get-ADOrganizationalUnit -Filter "Name -eq 'Disabled Users'" |
    Select-Object Name, DistinguishedName

The scope is larger than the OU I named. -SearchBase with the default Subtree scope walks into child OUs. Pin it deliberately with -SearchScope OneLevel, and exclude objects you do not want with a server-side filter rather than a post-filter:

Get-ADUser -Filter "SamAccountName -notlike 'svc-*'" -SearchBase $ou -SearchScope OneLevel -Properties $value

“Insufficient access rights to perform the operation.” Your account lacks write permission on that attribute, or the object is protected. Objects flagged adminCount = 1 have their permissions re-applied by the AdminSDHolder process on an hourly cycle, so any manual grant is temporary. Either exclude them from the scope or elevate deliberately.

The attribute name does not exist. Check the schema before writing the script rather than after it fails mid-run:

$schema = (Get-ADRootDSE).schemaNamingContext
Get-ADObject -SearchBase $schema -Filter "lDAPDisplayName -eq 'msExchHideFromAddressLists'" |
    Select-Object Name, lDAPDisplayName

An empty result means the Exchange schema extensions were never applied and no amount of scripting will help.

The reporting query returns nothing but the write succeeded. You are looking at the script-block filter discussed above. Replace it with the quoted string form, and make sure the attribute is listed in -Properties — the AD module returns only a default property set unless you ask for more, which is why the original script had to name $value explicitly.

The changes revert themselves overnight. Something else owns the attribute. On a hybrid tenant, Entra Connect's synchronisation rules can overwrite on-premises values on the next delta cycle, and an HR provisioning tool may do the same from the other direction. Check which system is authoritative for that attribute before you automate writing to it — otherwise you are fighting a scheduled job.

The GAL still shows the departed user. The attribute is set, but Exchange has not rebuilt the address list yet. On-premises Exchange caches the global address list and the offline address book, so a manual rebuild is often needed for the change to become visible:

# Exchange Management Shell, on-premises
Update-GlobalAddressList -Identity 'Default Global Address List'
Update-OfflineAddressBook -Identity 'Default Offline Address Book'

In a hybrid or cloud-only setup the equivalent step is to let the directory synchronisation run, or force it deliberately with Start-ADSyncSyncCycle -PolicyType Delta on the Entra Connect server.

The writes are slow on a large OU. Each object is an individual LDAP modify. Narrow the property set with -Properties so less data comes back, pin -Server to a single nearby writable domain controller to avoid referral lookups on every call, and schedule the run outside business hours.

What if I only want to change some of the users? Feed the loop a list instead of a search. Once $targets is a variable, it can come from a CSV, a group membership or a manual list without any other change to the script:

$targets = Get-ADGroupMember -Identity 'Offboarded-2026-09' -Recursive |
    Where-Object { $_.objectClass -eq 'user' } |
    ForEach-Object { Get-ADUser -Identity $_.distinguishedName -Properties $value, adminCount }

Related reading