Skip to content

Instantly share code, notes, and snippets.

@Bill-Stewart
Created September 4, 2025 18:54
Show Gist options
  • Save Bill-Stewart/60d81e8ae99310ba0c3c1c6ad188eef0 to your computer and use it in GitHub Desktop.
Save Bill-Stewart/60d81e8ae99310ba0c3c1c6ad188eef0 to your computer and use it in GitHub Desktop.
# Update-ADGroupFromLDAPQuery.ps1
# Written by Bill Stewart (bstewart AT iname.com)
<#
Notes about this script:
* Import the ActiveDirectory module BEFORE running this script; one easy way to
do this is to write a "wrapper" script that calls this one (e.g., if you want
to schedule this using the Task Scheduler).
* The -ExcludeMembers and -IncludeMembers parameters use objects of type
Microsoft.ActiveDirectory.Management.ADObject, so you can't use the
sAMAccountName attribute of objects for these parameters. Instead, use GUIDs
or SIDs and document what objects you're referring to in the wrapper script.
* The script updates the member attribute 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 script uses Write-Host output, so transcription can be useful from the
wrapper script for debugging/troubleshooting purposes.
* The script doesn't currently support specifying a directory server name,
credentials, binding type, etc.
* Tested adding and removing around 2000 objects. The script collects group
membership, exclude, and include lists in memory, so there may be performance
consequences with very large group memberships.
Sample wrapper script follows. Place the wrapper script in the same directory
as this script, and schedule the wrapper. This example populates the
FabrikamEmployees group with user objects that have 'Fabrikam' (no quotes) in
the company attribute.
-------------------------------------------------------------------------------
# Wrapper script for Update-ADGroupFromLDAPQuery.ps1
# Script file name: UpdateADGroupFromLDAPQuery.ps1
#requires -version 5.1
$TranscriptFileName = '{0}.log' -f
[IO.Path]::GetFileNameWithoutExtension($MyInvocation.MyCommand.Name)
Start-Transcript (Join-Path $PSScriptRoot $TranscriptFileName)
# Specify the -Append parameter if desired
try {
Import-Module ActiveDirectory -ErrorAction Stop
}
catch {
Stop-Transcript
exit
}
$params = @{
Identity = Get-ADGroup "FabrikamEmployees"
Query = "(&(objectClass=user)(company=Fabrikam))"
IncludeMembers = @(
[Guid] "4c754781-145b-45c5-929b-683b414c44ef" # CN=User1,OU=Users,...
[Guid] "9b4a40fb-7080-4075-9370-10dc11a4615b" # CN=Contact,OU=Contacts,...
)
ExcludeMembers = @(
[Guid] "d13a31a0-083e-4d70-ae78-5cfc9dc87479" # CN=ServiceAccount,OU=ServiceAccounts,...
)
}
& (Join-Path $PSScriptRoot "Update-ADGroupFromLDAPQuery.ps1") @params
Stop-Transcript
-------------------------------------------------------------------------------
The wrapper can be scheduled using the following command line, arguments, and
script path:
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 correct wrapper script file name and starting path, as
appropriate.
I would recommend using a Group Managed Service Account (gMSA) for executing
the script. You can grant the gMSA 'write member' permission for the needed AD
group(s). Naturally the gMSA would also need write access to the directory
containing the script files and the transcript file.
Version history:
2025-09-04
* Initial version.
#>
#requires -version 5.1
<#
.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.
.PARAMETER Identity
Specifies the Active Directory group whose membership is to be updated.
.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.
.PARAMETER ExcludeMembers
Specifies Active Directory objects that should not be members of the group even if they match the query.
.PARAMETER IncludeMembers
Specifies Active Directory objects that should be members of the group even if they do not match the query.
#>
[CmdletBinding()]
param(
[Parameter(Position = 0,Mandatory,ValueFromPipeline)]
[Microsoft.ActiveDirectory.Management.ADGroup]
$Identity,
[Parameter(Position = 1,Mandatory)]
[ValidateNotNullOrEmpty()]
[String]
$Query,
[Microsoft.ActiveDirectory.Management.ADObject[]]
$ExcludeMembers,
[Microsoft.ActiveDirectory.Management.ADObject[]]
$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://<SID={0}>" -f $adGroup.SID.Value)
$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 validity test fails
if ( -not (Test-LDAPQuery $Query) ) {
exit $Error[0].Exception.HResult
}
Write-Host ("Group: {0}" -f $adGroup.DistinguishedName)
# Collect GUIDs of current members
$currentMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = 0
Get-ADMember $adGroup | ForEach-Object {
$currentMemberGUIDs.Add($_.ObjectGUID)
$count++
}
Write-Host ("Current members: {0:N0}" -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 ) {
$excludedMemberGUIDs.Add($adObject.ObjectGUID)
$count++
}
}
Write-Host ("Exclude {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)) ) {
$includeMemberGUIDs.Add($adObject.ObjectGUID)
$count++
}
}
Write-Host ("Include {0:N0} member(s)" -f $count)
# Collect GUIDs of objects that match the query (i.e., should be members)
$validClasses = "computer","contact","group","msDS-GroupManagedServiceAccount","user"
$shouldBeMemberGUIDs = New-Object Collections.Generic.List[Guid]
$count = $excludedCount = 0
Write-Host "Query: $Query"
try {
$searcher = [ADSISearcher] $Query
$searcher.PropertiesToLoad.AddRange(@("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 ) {
$objectGUID = [Guid] $searchResult.Properties["objectGUID"][0]
# Skip excluded members
if ( -not $excludedMemberGUIDs.Contains($objectGUID) ) {
$shouldBeMemberGUIDs.Add($objectGUID)
}
else {
$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]
# Always add included members
$count = 0
Compare-Object $currentMemberGUIDs $includeMemberGUIDs | ForEach-Object {
$adObject = Get-ADObject $_.InputObject
if ( ($null -ne $adObject) -and ($_.SideIndicator -eq '=>') ) {
$addMemberDNs.Add($adObject.DistinguishedName)
$count++
}
}
Write-Host ("Include {0:N0} member(s)" -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 ) {
$removeMemberDNs.Add($adObject.DistinguishedName)
}
}
'=>' {
$addMemberDNs.Add($adObject.DistinguishedName)
}
}
}
}
$count = 0
foreach ( $memberDN in $removeMemberDNs ) {
Write-Host ("Remove: '{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: '{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