Last active
March 10, 2026 18:24
-
-
Save Bill-Stewart/60d81e8ae99310ba0c3c1c6ad188eef0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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