Created
September 4, 2025 18:54
-
-
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) | |
<# | |
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