Skip to content

Instantly share code, notes, and snippets.

@Bill-Stewart
Last active March 10, 2026 18:24
Show Gist options
  • Select an option

  • Save Bill-Stewart/60d81e8ae99310ba0c3c1c6ad188eef0 to your computer and use it in GitHub Desktop.

Select an option

Save Bill-Stewart/60d81e8ae99310ba0c3c1c6ad188eef0 to your computer and use it in GitHub Desktop.
# Update-ADGroupFromLDAPQuery.ps1
# Written by Bill Stewart (bstewart AT iname.com)
#requires -version 5.1
#requires -modules ActiveDirectory
<#
Notes about this script:
* The script updates the member attribute of the group rather than using the
Add-ADGroupMember and Remove-ADGroupMember cmdlets because those cmdlets
require security principals (and thus don't work with contact objects).
* The -ExcludeMembers and -IncludeMembers parameters ultimately use objects of
type Microsoft.ActiveDirectory.Management.ADObject, which lacks a
sAMAccountName attribute, so you can't use the sAMAccountName attribute of
objects for these parameters. You can specify distinguished names of objects,
but this is not recommended because objects can be renamed or moved to a
different container. Instead, use GUIDs and document which objects you're
referring to in your "wrapper" script.
* The script uses Write-Host output, so transcription can be useful from a
"wrapper" script for debugging/troubleshooting purposes.
* If using -Verbose, the script outputs the distinguished names of the members
of the group as well as the distinguished names of the objects matching the
LDAP query. If the group has a large membership and/or the LDAP query returns
a large number of objects, this can result in a lot of verbose output.
* The script doesn't currently support specifying a directory server name,
credentials, binding type, etc.
* Tested adding and removing up to 2000 objects. The script collects group
membership, query results, and the exclude and include lists in memory, so
there may be performance consequences or memory pressure with very large
group memberships and/or LDAP queries that return a large number of objects.
* For scripting scheduling, I would recommend using a Group Managed Service
Account (gMSA). You can grant the gMSA 'write member' permission for the
AD group(s) whose members you want to maintain.
Version history:
2026-03-10
* Added -SearchBase and -IncludeChildContainers parameters.
* Removed ActiveDirectory object typing from script parameters (so users don't
have to have the ActiveDirectory module imported just to view script help).
* Improved help text and Write-Host output.
* Added verbose output (group and LDAP query object enumerations).
* Minor tweaks.
2025-09-04
* Initial version.
#>
<#
.SYNOPSIS
Updates an Active Directory group's membership based on the results of an LDAP query.
.DESCRIPTION
Updates an Active Directory group's membership based on the results of an LDAP query. Active Directory objects matching the query will be added to the group, and objects that do not match the query will be removed from the group. You can specify objects that should always be excluded or included using the -ExcludeMembers and -IncludeMembers parameters.
.PARAMETER Identity
Specifies the Active Directory group whose membership is to be updated. This parameter can be any of the following:
* The group's distinguished name
* The group's sAMAccountName
* An ADGroup object (output from Get-ADGroup cmdlet)
* An ADObject object with an ObjectClass property of 'group'
* The group's object GUID
* The group's security identifier
.PARAMETER Query
Specifies the LDAP query. Active Directory objects matching the query will be added in the group, and objects that do not match the query will be removed from the group. You can specify exceptions to the query using the -ExcludeMembers and -IncludeMembers parameters.
.PARAMETER SearchBase
Specifies the search base for the LDAP query. The default is to search the current domain. The search base must be an organizational unit (OU), container, or domain. This parameter can be any of the following:
* An ADOrganizationalUnit object (output from Get-ADOrganizationalUnit cmdlet)
* An ADObject object with an ObjectClass property of one of the following:
- container
- domainDNS
- organizationalUnit
* The object's distinguished name
* The object's object GUID
.PARAMETER IncludeChildContainers
Specifies that the LDAP query should search for objects in child containers. Without this parameter, the LDAP query will search for objects in the search base location only. If you omit -SearchBase and don't specify -IncludeChildContainers, the LDAP query will search for objects only in the domain's root. If you omit -SearchBase but specify -IncludeChildContainers, the LDAP query will search for objects in the entire domain.
.PARAMETER ExcludeMembers
Specifies Active Directory objects that should not be members of the group even if they match the query. This parameter can be one or more distinguished names or object GUIDs. It is recommended to specify GUIDs rather than distinguished names to identify objects for this parameter.
.PARAMETER IncludeMembers
Specifies Active Directory objects that should be members of the group even if they do not match the query. This parameter can be one or more distinguished names or object GUIDs. It is recommended to specify GUIDs rather than distinguished names to identify objects for this parameter.
.INPUTS
String - You can pipe a string to specify the group's distinguishedName or sAMAccountName
ADGroup - You can pipe an ADGroup object to specify the group
ADObject - You can pipe an ADObject object with an ObjectClass of 'group' to specify the group
Guid - You can pipe a Guid to specify the group by object GUID
SecurityIdentifier - You can pipe a SecurityIdentifier to specify the group by SID
.OUTPUTS
None
.NOTES
To perform group updates on a schedule, it is recommended to deploy a "wrapper" script that employs transcription for troubleshooting purposes.
Sample wrapper script:
----------------------------------------------------------------------------
# Script: UpdateADGroupFromLDAPQuery.ps1
# Wrapper for Update-ADGroupFromLDAPQuery.ps1
#requires -version 5.1
$TranscriptFileName = '{0}.log' -f
[IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)
Start-Transcript (Join-Path $PSScriptRoot $TranscriptFileName)
$params = @{
Identity = Get-ADGroup "Fabrikam_Employees"
Query = "(&(objectClass=user)(employeeNumber=*))"
IncludeMembers = @(
[Guid] "d0726664-f509-4183-8db0-4f253ab28b1c" # Guid of user to include
)
ExcludeMembers = @(
[Guid] "2ad8f393-0ca7-422d-a79b-ee08bfa5c0c6" # Guid of user to exclude
)
SearchBase = "OU=Employees,DC=fabrikam,DC=local"
IncludeChildContainers = $true
}
& (Join-Path $PSScriptRoot "Update-ADGroupFromLDAPQuery.ps1") @params
Stop-Transcript
----------------------------------------------------------------------------
You can schedule the above script using the task scheduler:
Program/script: %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe
Arguments: -ExecutionPolicy Bypass -NonInteractive -NoProfile -File "UpdateADGroupFromLDAPQuery.ps1"
Start in: C:\Scripts\ScheduledTasks\UpdateADGroupFromLDAPQuery
Of course, specify the appropriate wrapper script file name and "Start in" path.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Position = 0,Mandatory,ValueFromPipeline)]
[ValidateNotNullOrEmpty()]
[Object]
$Identity,
[Parameter(Position = 1,Mandatory)]
[ValidateNotNullOrEmpty()]
[String]
$Query,
[ValidateNotNullOrEmpty()]
[Object]
$SearchBase,
[Switch]
$IncludeChildContainers,
[Object[]]
$ExcludeMembers,
[Object[]]
$IncludeMembers
)
# Outputs $true if the specified LDAP query is syntactically valid, or $false
# otherwise
function Test-LDAPQuery {
[CmdletBinding()]
param(
[Parameter(Position = 0,Mandatory)]
[String]
$query
)
$searcher = [ADSISearcher] $query
try {
[Void] $searcher.FindOne()
return $true
}
catch {
Write-Error -Exception $_.Exception.InnerException
return $false
}
}
# Outputs the members of the specified group's member attribute
# * Validate that the AD group exists before calling this
# * Outputs objects of type Microsoft.ActiveDirectory.Management.ADObject
# * Uses paged search to get all members
function Get-ADMember {
[CmdletBinding()]
param(
[Parameter(Position = 0,Mandatory)]
[Microsoft.ActiveDirectory.Management.ADGroup]
$adGroup
)
$searcher = [ADSISearcher] "(objectClass=*)"
$searcher.SearchRoot = [ADSI] ("LDAP://<GUID={0}>" -f $adGroup.ObjectGUID.Guid)
$searcher.SearchScope = [DirectoryServices.SearchScope]::Base
$lastQuery = $false
$rangeStep = 1500
$rangeLow = 0
$rangeHigh = $rangeLow + ($rangeStep - 1)
$count = 0
do {
if ( -not $lastQuery ) {
$property = "member;range={0}-{1}" -f $rangeLow,$rangeHigh
}
else {
$property = "member;range={0}-*" -f $rangeLow
}
$searcher.PropertiesToLoad.Clear()
[Void] $searcher.PropertiesToLoad.Add($property)
$searchResults = $searcher.FindOne()
if ( $searchResults.Properties.Contains($property) ) {
foreach ( $searchResult in $searchResults.Properties[$property] ) {
if ( ($count -gt 0) -and (($count % $rangeStep) -eq 0) ) {
Write-Progress `
-Activity $MyInvocation.MyCommand.Name `
-Status ("Getting members of '{0}'" -f $adGroup.DistinguishedName) `
-CurrentOperation ("Count: {0:N0}" -f $count)
}
Get-ADObject $searchResult
$count++
}
$done = $lastQuery
}
else {
if ( -not $lastQuery ) {
$lastQuery = $true
}
else {
$done = $true
}
}
if ( -not $lastQuery ) {
$rangeLow = $rangeHigh + 1
$rangeHigh = $rangeLow + ($rangeStep - 1)
}
}
until ( $done )
Write-Progress `
-Activity $MyInvocation.MyCommand.Name `
-Status ("Getting members of '{0}'" -f $adGroup.DistinguishedName) `
-Completed:$true
}
# Exit script if we can't find the group
$adGroup = Get-ADGroup $Identity
if ( $null -eq $adGroup ) {
exit $Error[0].Exception.HResult
}
# Exit script if LDAP query not valid
if ( -not (Test-LDAPQuery $Query) ) {
exit $Error[0].Exception.HResult
}
# Exit script if we can't find the search base or if object isn't a valid type
if ( $SearchBase ) {
$searchRoot = Get-ADObject $SearchBase
if ( $null -eq $searchRoot ) {
exit $Error[0].Exception.HResult
}
if ( "container","domainDNS","organizationalUnit" -notContains $searchRoot.ObjectClass ) {
Write-Error "Specified object is not a container, domain, or organizational unit." -Category InvalidType
exit 87 # ERROR_INVALID_PARAMETER
}
}
# Collect GUIDs of current members
Write-Host ("Group: '{0}'" -f $adGroup.DistinguishedName)
Write-Host "Enumerating group membership..."
$currentMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = 0
Get-ADMember $adGroup | ForEach-Object {
Write-Verbose ("Member: '{0}'" -f $_.DistinguishedName)
$currentMemberGUIDs.Add($_.ObjectGUID.Guid)
$count++
}
Write-Host ("Group contains {0:N0} member(s)" -f $count)
# Collect GUIDs of members to exclude
$excludedMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = 0
foreach ( $excludedMember in $ExcludeMembers ) {
$adObject = Get-ADObject $excludedMember
if ( $null -ne $adObject ) {
Write-Host ("-ExcludeMembers specifies '{0}'" -f $adObject.DistinguishedName)
$excludedMemberGUIDs.Add($adObject.ObjectGUID.Guid)
$count++
}
}
Write-Host ("-ExcludeMembers specifies {0:N0} member(s)" -f $count)
# Collect GUIDs of members that should always be included
$includeMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = 0
foreach ( $includeMember in $IncludeMembers ) {
$adObject = Get-ADObject $includeMember
if ( ($null -ne $adObject) -and (-not $includeMemberGUIDs.Contains($adObject.ObjectGUID.Guid)) ) {
Write-Host ("-IncludeMembers specifies '{0}'" -f $adObject.DistinguishedName)
$includeMemberGUIDs.Add($adObject.ObjectGUID.Guid)
$count++
}
}
Write-Host ("-IncludeMembers specifies {0:N0} member(s)" -f $count)
# Collect GUIDs of objects that match the query (i.e., should be members),
# but do not include excluded members
$validClasses = "computer","contact","group","msDS-GroupManagedServiceAccount","user"
$shouldBeMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = $excludedCount = 0
Write-Host "LDAP query: '$Query'"
if ( $SearchBase ) { Write-Host ("LDAP query search base: '{0}'" -f $searchRoot.DistinguishedName) }
$searchScope = ([DirectoryServices.SearchScope]::OneLevel,[DirectoryServices.SearchScope]::Subtree)[$IncludeChildContainers.IsPresent]
Write-Host ("Include child containers: {0}" -f $IncludeChildContainers.IsPresent.ToString())
try {
$searcher = [ADSISearcher] $Query
if ( $SearchBase ) {
$searcher.SearchRoot = [ADSI] ("LDAP://<GUID={0}>" -f $searchRoot.ObjectGUID.Guid)
}
$searcher.SearchScope = $searchScope
$searcher.PropertiesToLoad.AddRange(@("distinguishedName","objectClass","objectGUID"))
$searcher.PageSize = 1000
$searchResults = $searcher.FindAll()
foreach ( $searchResult in $searchResults ) {
$objectClass = $searchResult.Properties["objectClass"][$searchResult.Properties["objectClass"].Count - 1]
# Only include certain object types
if ( $validClasses -contains $objectClass ) {
$objectDN = $searchResult.Properties["distinguishedName"][0]
$objectGUID = [Guid] $searchResult.Properties["objectGUID"][0]
# Skip excluded members
if ( -not $excludedMemberGUIDs.Contains($objectGUID) ) {
Write-Verbose ("Matched: '{0}'" -f $objectDN)
$shouldBeMemberGUIDs.Add($objectGUID)
}
else {
Write-Verbose ("Excluded: '{0}'" -f $objectDN)
$excludedCount++
}
$count++
}
}
}
finally {
$searchResults.Dispose()
}
Write-Host ("{0:N0} object(s) matched query; {1:N0} object(s) excluded" -f $count,$excludedCount)
# Collect lists of DNs of members to be removed and added
$removeMemberDNs = New-Object Collections.Generic.List[String]
$addMemberDNs = New-Object Collections.Generic.List[String]
# Add included members that aren't already in the group
$count = 0
Compare-Object $currentMemberGUIDs $includeMemberGUIDs | ForEach-Object {
$adObject = Get-ADObject $_.InputObject
if ( ($null -ne $adObject) -and ($_.SideIndicator -eq '=>') ) {
Write-Host ("Include member not already in group: '{0}'" -f $adObject.DistinguishedName)
$addMemberDNs.Add($adObject.DistinguishedName)
$count++
}
}
Write-Host ("Include {0:N0} member(s) not already in group" -f $count)
# Collect members to remove and add
Compare-Object $currentMemberGUIDs $shouldBeMemberGUIDs | ForEach-Object {
$adObject = Get-ADObject $_.InputObject
if ( $null -ne $adObject ) {
switch ( $_.SideIndicator ) {
'<=' {
# Ignore included members when evaluating what to remove
if ( $includeMemberGUIDs -notcontains $adObject.ObjectGUID.Guid ) {
$removeMemberDNs.Add($adObject.DistinguishedName)
}
}
'=>' {
$addMemberDNs.Add($adObject.DistinguishedName)
}
}
}
}
$count = 0
foreach ( $memberDN in $removeMemberDNs ) {
Write-Host ("Remove member: '{0}'" -f $memberDN)
$adGroup | Set-ADGroup -Remove @{"member" = $memberDN}
$count += (0,1)[$? -as [Int]]
}
Write-Host ("Removed {0:N0} member(s)" -f $count)
$count = 0
foreach ( $memberDN in $addMemberDNs ) {
Write-Host ("Add member: '{0}'" -f $memberDN)
$adGroup | Set-ADGroup -Add @{"member" = $memberDN}
$count += (0,1)[$? -as [Int]]
}
Write-Host ("Added {0:N0} member(s)" -f $count)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment